mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
Merge branch 'dev'
This commit is contained in:
commit
96e0e8640d
@ -1,4 +1,4 @@
|
||||
{
|
||||
"flutterSdkVersion": "3.10.0",
|
||||
"flutterSdkVersion": "3.16.0",
|
||||
"flavors": {}
|
||||
}
|
15
.github/workflows/spotube-release-binary.yml
vendored
15
.github/workflows/spotube-release-binary.yml
vendored
@ -4,7 +4,7 @@ on:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to release (x.x.x)
|
||||
default: 3.2.0
|
||||
default: 3.3.0
|
||||
required: true
|
||||
channel:
|
||||
type: choice
|
||||
@ -26,13 +26,18 @@ on:
|
||||
default: true
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: '3.13.2'
|
||||
FLUTTER_VERSION: '3.16.0'
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: KRTirtho/flutter_distributor
|
||||
path: flutter_distributor
|
||||
ref: fix-windows-build
|
||||
- uses: subosito/flutter-action@v2.10.0
|
||||
with:
|
||||
cache: true
|
||||
@ -74,9 +79,10 @@ jobs:
|
||||
|
||||
- name: Build Windows Executable
|
||||
run: |
|
||||
dart pub global activate flutter_distributor
|
||||
dart pub global activate melos
|
||||
cd flutter_distributor && melos bs && cd ..
|
||||
make innoinstall
|
||||
flutter_distributor package --platform=windows --targets=exe --skip-clean
|
||||
dart run ./flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=windows --targets=exe --skip-clean
|
||||
mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe
|
||||
|
||||
- name: Create Chocolatey Package and set hash
|
||||
@ -319,6 +325,7 @@ jobs:
|
||||
|
||||
- name: Package Macos App
|
||||
run: |
|
||||
python3 -m pip install setuptools
|
||||
npm install -g appdmg
|
||||
mkdir -p build/${{ env.BUILD_VERSION }}
|
||||
appdmg appdmg.json build/Spotube-macos-universal.dmg
|
||||
|
38
CHANGELOG.md
38
CHANGELOG.md
@ -2,6 +2,44 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
## [3.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
|
||||
|
||||
* 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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
32
README.md
32
README.md
@ -2,7 +2,7 @@
|
||||
<img width="600" src="assets/spotube_banner.png" alt="Spotube Logo">
|
||||
|
||||
An open source, cross-platform Spotify client compatible across multiple platforms<br />
|
||||
utilizing Spotify's data API and YouTube (or Piped.video) as an audio source,<br />
|
||||
utilizing Spotify's data API and YouTube (or Piped.video or JioSaavn) as an audio source,<br />
|
||||
eliminating the need for Spotify Premium
|
||||
|
||||
Btw it's not another Electron app😉
|
||||
@ -184,19 +184,23 @@ If you are concerned, you can [read the reason of choosing this license](https:/
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<h2><code>[Click to show]</code> 🙏 Library/Plugin/Framework Credits</h2>
|
||||
<h2><code>[Click to show]</code> 🙏 Services/Package/Plugin Credits</h2>
|
||||
</summary>
|
||||
|
||||
### 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.
|
||||
</details>
|
||||
|
||||
<div align="center"><h4>© Copyright Spotube 2023</h4></div>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:envied/envied.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
|
||||
part 'env.g.dart';
|
||||
|
||||
@ -24,5 +25,8 @@ abstract class Env {
|
||||
@EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1")
|
||||
static final String _enableUpdateChecker = _Env._enableUpdateChecker;
|
||||
|
||||
static bool get enableUpdateChecker => _enableUpdateChecker == "1";
|
||||
static bool get enableUpdateChecker =>
|
||||
DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1";
|
||||
|
||||
static String discordAppId = "1176718791388975124";
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:spotube/components/player/player_controls.dart';
|
||||
@ -115,7 +116,7 @@ class CloseAppAction extends Action<CloseAppIntent> {
|
||||
@override
|
||||
invoke(intent) {
|
||||
if (kIsDesktop) {
|
||||
DesktopTools.window.close();
|
||||
exit(0);
|
||||
} else {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
|
@ -660,10 +660,10 @@ abstract class LanguageLocals {
|
||||
// name: "Tonga (Tonga Islands)",
|
||||
// nativeName: "faka Tonga",
|
||||
// ),
|
||||
// "tr": const ISOLanguageName(
|
||||
// name: "Turkish",
|
||||
// nativeName: "Türkçe",
|
||||
// ),
|
||||
"tr": const ISOLanguageName(
|
||||
name: "Turkish",
|
||||
nativeName: "Türkçe",
|
||||
),
|
||||
// "ts": const ISOLanguageName(
|
||||
// name: "Tsonga",
|
||||
// nativeName: "Xitsonga",
|
||||
|
@ -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<NavigatorState>();
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -4,10 +4,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/infinite_query.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/queries/album.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
@ -16,7 +18,7 @@ extension FormattedAlbumType on AlbumType {
|
||||
}
|
||||
|
||||
class AlbumCard extends HookConsumerWidget {
|
||||
final Album album;
|
||||
final AlbumSimple album;
|
||||
const AlbumCard(
|
||||
this.album, {
|
||||
Key? key,
|
||||
@ -28,30 +30,54 @@ class AlbumCard extends HookConsumerWidget {
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
final queryClient = useQueryClient();
|
||||
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() => playlist.containsCollection(album.id!),
|
||||
[playlist, album.id],
|
||||
);
|
||||
|
||||
final marginH = useBreakpointValue<int>(
|
||||
xs: 10,
|
||||
sm: 10,
|
||||
md: 15,
|
||||
others: 20,
|
||||
);
|
||||
|
||||
final updating = useState(false);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
|
||||
|
||||
Future<List<Track>> fetchAllTrack() async {
|
||||
if (album.tracks != null && album.tracks!.isNotEmpty) {
|
||||
return album.tracks!
|
||||
.map((track) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||
.toList();
|
||||
}
|
||||
final job = AlbumQueries.tracksOfJob(album.id!);
|
||||
|
||||
final query = queryClient.createInfiniteQuery(
|
||||
job.queryKey,
|
||||
(page) => job.task(page, (spotify: spotify, album: album)),
|
||||
initialPage: 0,
|
||||
nextPage: job.nextPage,
|
||||
);
|
||||
|
||||
return await query.fetchAllTracks(
|
||||
getAllTracks: () async {
|
||||
final res = await spotify.albums.tracks(album.id!).all();
|
||||
return res
|
||||
.map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
|
||||
.toList();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return PlaybuttonCard(
|
||||
imageUrl: TypeConversionUtils.image_X_UrlString(
|
||||
album.images,
|
||||
placeholder: ImagePlaceholder.collection,
|
||||
),
|
||||
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
isPlaying: isPlaylistPlaying,
|
||||
isLoading: isPlaylistPlaying && playlist.isFetching == true,
|
||||
isLoading: (isPlaylistPlaying && playlist.isFetching == true) ||
|
||||
updating.value,
|
||||
title: album.name!,
|
||||
description:
|
||||
"${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
|
||||
@ -61,20 +87,15 @@ class AlbumCard extends HookConsumerWidget {
|
||||
onPlaybuttonPressed: () async {
|
||||
updating.value = true;
|
||||
try {
|
||||
if (isPlaylistPlaying && playing) {
|
||||
return audioPlayer.pause();
|
||||
} else if (isPlaylistPlaying && !playing) {
|
||||
return audioPlayer.resume();
|
||||
if (isPlaylistPlaying) {
|
||||
return playing ? audioPlayer.pause() : audioPlayer.resume();
|
||||
}
|
||||
|
||||
await playlistNotifier.load(
|
||||
album.tracks
|
||||
?.map((e) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(e, album))
|
||||
.toList() ??
|
||||
[],
|
||||
autoPlay: true,
|
||||
);
|
||||
final fetchedTracks = await fetchAllTrack();
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
@ -87,28 +108,16 @@ class AlbumCard extends HookConsumerWidget {
|
||||
|
||||
updating.value = true;
|
||||
try {
|
||||
final fetchedTracks =
|
||||
await queryClient.fetchQuery<List<TrackSimple>, SpotifyApi>(
|
||||
"album-tracks/${album.id}",
|
||||
() {
|
||||
return spotify.albums
|
||||
.getTracks(album.id!)
|
||||
.all()
|
||||
.then((value) => value.toList());
|
||||
},
|
||||
).then(
|
||||
(tracks) => tracks
|
||||
?.map(
|
||||
(e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
|
||||
.toList(),
|
||||
);
|
||||
final fetchedTracks = await fetchAllTrack();
|
||||
|
||||
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
playlistNotifier.addTracks(fetchedTracks);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
content: Text("Added ${album.tracks?.length} tracks to queue"),
|
||||
content: Text(
|
||||
context.l10n.added_to_queue(fetchedTracks.length),
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: "Undo",
|
||||
onPressed: () {
|
||||
@ -117,7 +126,8 @@ class AlbumCard extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
|
||||
|
||||
scaffoldMessenger?.showSnackBar(snackbar);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
|
@ -1,11 +1,9 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/album/album_card.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
|
||||
import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
@ -20,7 +18,6 @@ class ArtistAlbumList extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final scrollController = useScrollController();
|
||||
final albumsQuery = useQueries.artist.albumsOf(ref, artistId);
|
||||
|
||||
final albums = useMemoized(() {
|
||||
@ -29,40 +26,17 @@ class ArtistAlbumList extends HookConsumerWidget {
|
||||
.toList();
|
||||
}, [albumsQuery.pages]);
|
||||
|
||||
final hasNextPage = albumsQuery.pages.isEmpty
|
||||
? false
|
||||
: (albumsQuery.pages.last.items?.length ?? 0) == 5;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
},
|
||||
),
|
||||
child: Scrollbar(
|
||||
interactive: false,
|
||||
controller: scrollController,
|
||||
child: Waypoint(
|
||||
controller: scrollController,
|
||||
onTouchEdge: albumsQuery.fetchNext,
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...albums.map((album) => AlbumCard(album)),
|
||||
if (hasNextPage) const ShimmerPlaybuttonCard(count: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return HorizontalPlaybuttonCardView<Album>(
|
||||
isLoadingNextPage: albumsQuery.isLoadingNextPage,
|
||||
hasNextPage: albumsQuery.hasNextPage,
|
||||
items: albums,
|
||||
onFetchMore: albumsQuery.fetchNext,
|
||||
title: Text(
|
||||
context.l10n.albums,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,8 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/use_brightness_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
@ -63,7 +63,7 @@ class TokenLoginForm extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
final cookieHeader =
|
||||
"sp_dc=${directCodeController.text}; sp_key=${keyCodeController.text}";
|
||||
"sp_dc=${directCodeController.text.trim()}; sp_key=${keyCodeController.text.trim()}";
|
||||
|
||||
authenticationNotifier.setCredentials(
|
||||
await AuthenticationCredentials.fromCookie(
|
||||
|
@ -1,13 +1,10 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/playlist/playlist_card.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
|
||||
import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
@ -22,57 +19,33 @@ class CategoryCard extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final scrollController = useScrollController();
|
||||
final playlistQuery = useQueries.category.playlistsOf(
|
||||
ref,
|
||||
category.id!,
|
||||
);
|
||||
|
||||
if (playlistQuery.hasErrors && !playlistQuery.hasPageData) {
|
||||
final playlists = useMemoized(
|
||||
() => playlistQuery.pages.expand(
|
||||
(page) {
|
||||
return page.items?.whereNotNull() ??
|
||||
const Iterable<PlaylistSimple>.empty();
|
||||
},
|
||||
).toList(),
|
||||
[playlistQuery.pages],
|
||||
);
|
||||
|
||||
if (playlistQuery.hasErrors &&
|
||||
!playlistQuery.hasPageData &&
|
||||
!playlistQuery.isLoadingNextPage) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final playlists = playlistQuery.pages.expand(
|
||||
(page) {
|
||||
return page.items?.where((i) => i != null) ?? const Iterable.empty();
|
||||
},
|
||||
).toList();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.name!,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
},
|
||||
),
|
||||
child: Waypoint(
|
||||
controller: scrollController,
|
||||
onTouchEdge: playlistQuery.fetchNext,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...playlists.map((playlist) => PlaylistCard(playlist)),
|
||||
if (playlistQuery.hasNextPage)
|
||||
const ShimmerPlaybuttonCard(count: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
return HorizontalPlaybuttonCardView<PlaylistSimple>(
|
||||
title: Text(category.name!),
|
||||
isLoadingNextPage: playlistQuery.isLoadingNextPage,
|
||||
hasNextPage: playlistQuery.hasNextPage,
|
||||
items: playlists,
|
||||
onFetchMore: playlistQuery.fetchNext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<DownloadStatus?>(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 ?? <Artist>[],
|
||||
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);
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:catcher_2/catcher_2.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@ -12,7 +11,6 @@ import 'package:metadata_god/metadata_god.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
@ -20,17 +18,14 @@ import 'package:spotube/components/shared/expandable_search/expandable_search.da
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
|
||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_async_effect.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'
|
||||
show FfiException;
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
|
||||
|
||||
const supportedAudioTypes = [
|
||||
"audio/webm",
|
||||
@ -162,39 +157,13 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
final trackSnapshot = ref.watch(localTracksProvider);
|
||||
final isPlaylistPlaying =
|
||||
playlist.containsTracks(trackSnapshot.value ?? []);
|
||||
final isMounted = useIsMounted();
|
||||
|
||||
final searchController = useTextEditingController();
|
||||
useValueListenable(searchController);
|
||||
final searchFocus = useFocusNode();
|
||||
final isFiltering = useState(false);
|
||||
|
||||
useAsyncEffect(
|
||||
() async {
|
||||
if (!kIsMobile) return;
|
||||
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
|
||||
final hasNoStoragePerm = androidInfo.version.sdkInt < 33 &&
|
||||
!await Permission.storage.isGranted &&
|
||||
!await Permission.storage.isLimited;
|
||||
|
||||
final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 &&
|
||||
!await Permission.audio.isGranted &&
|
||||
!await Permission.audio.isLimited;
|
||||
|
||||
if (hasNoStoragePerm) {
|
||||
await Permission.storage.request();
|
||||
if (isMounted()) ref.refresh(localTracksProvider);
|
||||
}
|
||||
if (hasNoAudioPerm) {
|
||||
await Permission.audio.request();
|
||||
if (isMounted()) ref.refresh(localTracksProvider);
|
||||
}
|
||||
},
|
||||
null,
|
||||
[],
|
||||
);
|
||||
final controller = useScrollController();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@ -230,7 +199,8 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
ExpandableSearchButton(
|
||||
isFiltering: isFiltering,
|
||||
isFiltering: isFiltering.value,
|
||||
onPressed: (value) => isFiltering.value = value,
|
||||
searchFocus: searchFocus,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
@ -253,7 +223,8 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
ExpandableSearchField(
|
||||
searchController: searchController,
|
||||
searchFocus: searchFocus,
|
||||
isFiltering: isFiltering,
|
||||
isFiltering: isFiltering.value,
|
||||
onChangeFiltering: (value) => isFiltering.value = value,
|
||||
),
|
||||
trackSnapshot.when(
|
||||
data: (tracks) {
|
||||
@ -289,7 +260,9 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
ref.refresh(localTracksProvider);
|
||||
},
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: filteredTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
@ -313,7 +286,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Expanded(child: ShimmerTrackTile(noSliver: true)),
|
||||
const Expanded(child: ShimmerTrackTileGroup(noSliver: true)),
|
||||
error: (error, stackTrace) =>
|
||||
Text(error.toString() + stackTrace.toString()),
|
||||
)
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart' hide Image;
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@ -13,6 +14,7 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'
|
||||
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
|
||||
import 'package:spotube/components/playlist/playlist_card.dart';
|
||||
import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
@ -80,64 +82,73 @@ class UserPlaylists extends HookConsumerWidget {
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: playlistsQuery.refresh,
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Waypoint(
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
onTouchEdge: () {
|
||||
if (playlistsQuery.hasNextPage) {
|
||||
playlistsQuery.fetchNext();
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: SearchBar(
|
||||
onChanged: (value) => searchText.value = value,
|
||||
hintText: context.l10n.filter_playlists,
|
||||
leading: const Icon(SpotubeIcons.filter),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: SearchBar(
|
||||
onChanged: (value) => searchText.value = value,
|
||||
hintText: context.l10n.filter_playlists,
|
||||
leading: const Icon(SpotubeIcons.filter),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
crossFadeState: !playlistsQuery.hasPageData &&
|
||||
!playlistsQuery.hasPageError &&
|
||||
!playlistsQuery.isLoadingNextPage
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
firstChild:
|
||||
const Center(child: ShimmerPlaybuttonCard(count: 7)),
|
||||
secondChild: Wrap(
|
||||
runSpacing: 10,
|
||||
alignment: WrapAlignment.center,
|
||||
Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 10),
|
||||
const PlaylistCreateDialogButton(),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(SpotubeIcons.magic),
|
||||
label: Text(context.l10n.generate_playlist),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).push("/library/generate");
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
const PlaylistCreateDialogButton(),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(SpotubeIcons.magic),
|
||||
label: Text(context.l10n.generate_playlist),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).push("/library/generate");
|
||||
},
|
||||
),
|
||||
...playlists.map((playlist) => PlaylistCard(playlist))
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 10),
|
||||
),
|
||||
SliverLayoutBuilder(builder: (context, constrains) {
|
||||
return SliverGrid.builder(
|
||||
itemCount: playlists.length + 1,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 200,
|
||||
mainAxisExtent: constrains.smAndDown ? 225 : 250,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == playlists.length) {
|
||||
if (!playlistsQuery.hasNextPage) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Waypoint(
|
||||
controller: controller,
|
||||
isGrid: true,
|
||||
onTouchEdge: playlistsQuery.fetchNext,
|
||||
child: const ShimmerPlaybuttonCard(count: 1),
|
||||
);
|
||||
}
|
||||
|
||||
return PlaylistCard(playlists[index]);
|
||||
},
|
||||
);
|
||||
})
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -18,8 +18,8 @@ import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/panels/sliding_up_panel.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
|
||||
import 'package:spotube/hooks/use_palette_color.dart';
|
||||
import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart';
|
||||
import 'package:spotube/hooks/utils/use_palette_color.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
|
@ -5,10 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart' hide Offset;
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/player/player_queue.dart';
|
||||
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
|
||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
||||
import 'package:spotube/components/shared/heart_button.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
@ -35,6 +35,7 @@ class PlayerActions extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final isLocalTrack = playlist.activeTrack is LocalTrack;
|
||||
ref.watch(downloadManagerProvider);
|
||||
@ -86,23 +87,7 @@ class PlayerActions extends HookConsumerWidget {
|
||||
tooltip: context.l10n.queue,
|
||||
onPressed: playlist.activeTrack != null
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isDismissible: true,
|
||||
enableDrag: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.black12,
|
||||
barrierColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * .7,
|
||||
),
|
||||
builder: (context) {
|
||||
return PlayerQueue(floating: floatingQueue);
|
||||
},
|
||||
);
|
||||
Scaffold.of(context).openEndDrawer();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
@ -119,6 +104,7 @@ class PlayerActions extends HookConsumerWidget {
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.black12,
|
||||
barrierColor: Colors.black12,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
|
@ -8,7 +8,7 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/collections/intents.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/hooks/use_progress.dart';
|
||||
import 'package:spotube/components/player/use_progress.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
|
@ -9,7 +9,7 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart';
|
||||
import 'package:spotube/components/shared/panels/sliding_up_panel.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/collections/intents.dart';
|
||||
import 'package:spotube/hooks/use_progress.dart';
|
||||
import 'package:spotube/components/player/use_progress.dart';
|
||||
import 'package:spotube/components/player/player.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
|
@ -11,10 +11,10 @@ import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
|
||||
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
@ -36,12 +36,15 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
|
||||
final tracks = playlist.tracks;
|
||||
final borderRadius = floating
|
||||
? BorderRadius.circular(10)
|
||||
? const BorderRadius.only(
|
||||
topLeft: Radius.circular(10),
|
||||
)
|
||||
: const BorderRadius.only(
|
||||
topLeft: Radius.circular(10),
|
||||
topRight: Radius.circular(10),
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final headlineColor = theme.textTheme.headlineSmall?.color;
|
||||
|
||||
final filteredTracks = useMemoized(
|
||||
@ -80,47 +83,49 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
return const NotFound(vertical: true);
|
||||
}
|
||||
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 12.0,
|
||||
sigmaY: 12.0,
|
||||
),
|
||||
child: Container(
|
||||
margin: EdgeInsets.all(floating ? 8.0 : 0),
|
||||
padding: const EdgeInsets.only(
|
||||
top: 5.0,
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 15,
|
||||
sigmaY: 15,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.scaffoldBackgroundColor.withOpacity(0.5),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
||||
if (!isSearching.value) {
|
||||
Navigator.of(context).pop();
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 5.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
||||
if (!isSearching.value) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
isSearching.value = false;
|
||||
searchText.value = '';
|
||||
}
|
||||
isSearching.value = false;
|
||||
searchText.value = '';
|
||||
}
|
||||
},
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
return Column(
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 5,
|
||||
width: 100,
|
||||
margin: const EdgeInsets.only(bottom: 5, top: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: headlineColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
if (!floating)
|
||||
Container(
|
||||
height: 5,
|
||||
width: 100,
|
||||
margin: const EdgeInsets.only(bottom: 5, top: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: headlineColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (constraints.mdAndUp || !isSearching.value) ...[
|
||||
if (mediaQuery.mdAndUp || !isSearching.value) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
context.l10n.tracks_in_queue(tracks.length),
|
||||
@ -132,7 +137,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
if (constraints.mdAndUp || isSearching.value)
|
||||
if (mediaQuery.mdAndUp || isSearching.value)
|
||||
TextField(
|
||||
onChanged: (value) {
|
||||
searchText.value = value;
|
||||
@ -140,7 +145,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.search,
|
||||
isDense: true,
|
||||
prefixIcon: constraints.smAndDown
|
||||
prefixIcon: mediaQuery.smAndDown
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new_outlined,
|
||||
@ -157,8 +162,8 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
: const Icon(SpotubeIcons.filter),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: 40,
|
||||
maxWidth: constraints.smAndDown
|
||||
? constraints.maxWidth - 20
|
||||
maxWidth: mediaQuery.smAndDown
|
||||
? mediaQuery.size.width - 40
|
||||
: 300,
|
||||
),
|
||||
),
|
||||
@ -170,7 +175,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
isSearching.value = !isSearching.value;
|
||||
},
|
||||
),
|
||||
if (constraints.mdAndUp || !isSearching.value) ...[
|
||||
if (mediaQuery.mdAndUp || !isSearching.value) ...[
|
||||
const SizedBox(width: 10),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
@ -197,51 +202,50 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
const SizedBox(height: 10),
|
||||
if (!isSearching.value && searchText.value.isEmpty)
|
||||
Flexible(
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
child: ReorderableListView.builder(
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
playlistNotifier.moveTrack(oldIndex, newIndex);
|
||||
},
|
||||
scrollController: controller,
|
||||
itemCount: tracks.length,
|
||||
shrinkWrap: true,
|
||||
buildDefaultDragHandles: false,
|
||||
itemBuilder: (context, i) {
|
||||
final track = tracks.elementAt(i);
|
||||
return AutoScrollTag(
|
||||
key: ValueKey(i),
|
||||
controller: controller,
|
||||
index: i,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: TrackTile(
|
||||
index: i,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
if (playlist.activeTrack?.id == track.id) {
|
||||
return;
|
||||
}
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
},
|
||||
leadingActions: [
|
||||
ReorderableDragStartListener(
|
||||
index: i,
|
||||
child: const Icon(SpotubeIcons.dragHandle),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ReorderableListView.builder(
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
playlistNotifier.moveTrack(oldIndex, newIndex);
|
||||
},
|
||||
scrollController: controller,
|
||||
itemCount: tracks.length,
|
||||
shrinkWrap: true,
|
||||
buildDefaultDragHandles: false,
|
||||
itemBuilder: (context, i) {
|
||||
final track = tracks.elementAt(i);
|
||||
return AutoScrollTag(
|
||||
key: ValueKey(i),
|
||||
controller: controller,
|
||||
index: i,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: TrackTile(
|
||||
index: i,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
if (playlist.activeTrack?.id == track.id) {
|
||||
return;
|
||||
}
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
},
|
||||
leadingActions: [
|
||||
ReorderableDragStartListener(
|
||||
index: i,
|
||||
child: const Icon(SpotubeIcons.dragHandle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: filteredTracks.length,
|
||||
itemBuilder: (context, i) {
|
||||
final track = filteredTracks.elementAt(i);
|
||||
@ -264,8 +268,8 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@ -11,13 +12,14 @@ import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/hooks/use_debounce.dart';
|
||||
import 'package:spotube/models/matched_track.dart';
|
||||
import 'package:spotube/models/spotube_track.dart';
|
||||
import 'package:spotube/hooks/utils/use_debounce.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/youtube_provider.dart';
|
||||
import 'package:spotube/services/youtube/youtube.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
import 'package:spotube/services/sourced_track/models/source_info.dart';
|
||||
import 'package:spotube/services/sourced_track/models/video_info.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
@ -34,7 +36,6 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final preferences = ref.watch(userPreferencesProvider);
|
||||
final youtube = ref.watch(youtubeProvider);
|
||||
|
||||
final isSearching = useState(false);
|
||||
final searchMode = useState(preferences.searchMode);
|
||||
@ -56,20 +57,35 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
||||
useValueListenable(searchController).text,
|
||||
);
|
||||
|
||||
final controller = useScrollController();
|
||||
|
||||
final searchRequest = useMemoized(() async {
|
||||
if (searchTerm.trim().isEmpty) {
|
||||
return <YoutubeVideoInfo>[];
|
||||
return <SourceInfo>[];
|
||||
}
|
||||
|
||||
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
|
||||
: <YoutubeVideoInfo>[];
|
||||
final siblings = useMemoized(
|
||||
() => playlist.isFetching == false
|
||||
? [
|
||||
(playlist.activeTrack as SourcedTrack).sourceInfo,
|
||||
...(playlist.activeTrack as SourcedTrack).siblings,
|
||||
]
|
||||
: <SourceInfo>[],
|
||||
[playlist.isFetching, playlist.activeTrack],
|
||||
);
|
||||
|
||||
final borderRadius = floating
|
||||
? BorderRadius.circular(10)
|
||||
@ -79,158 +95,166 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (playlist.activeTrack is SpotubeTrack &&
|
||||
(playlist.activeTrack as SpotubeTrack).siblings.isEmpty) {
|
||||
if (playlist.activeTrack is SourcedTrack &&
|
||||
(playlist.activeTrack as SourcedTrack).siblings.isEmpty) {
|
||||
playlistNotifier.populateSibling();
|
||||
}
|
||||
return null;
|
||||
}, [playlist.activeTrack]);
|
||||
|
||||
final itemBuilder = useCallback((YoutubeVideoInfo video) {
|
||||
return ListTile(
|
||||
title: Text(video.title),
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: UniversalImage(
|
||||
path: video.thumbnailUrl,
|
||||
height: 60,
|
||||
width: 60,
|
||||
final itemBuilder = useCallback(
|
||||
(SourceInfo sourceInfo) {
|
||||
return ListTile(
|
||||
title: Text(sourceInfo.title),
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: UniversalImage(
|
||||
path: sourceInfo.thumbnail,
|
||||
height: 60,
|
||||
width: 60,
|
||||
),
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
trailing: Text(video.duration.toHumanReadableString()),
|
||||
subtitle: Text(video.channelName),
|
||||
enabled: playlist.isFetching != true,
|
||||
selected: playlist.isFetching != true &&
|
||||
video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id,
|
||||
selectedTileColor: theme.popupMenuTheme.color,
|
||||
onTap: () {
|
||||
if (playlist.isFetching == false &&
|
||||
video.id != (playlist.activeTrack as SpotubeTrack).ytTrack.id) {
|
||||
playlistNotifier.swapSibling(video);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [
|
||||
playlist.isFetching,
|
||||
playlist.activeTrack,
|
||||
siblings,
|
||||
]);
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
trailing: Text(sourceInfo.duration.toHumanReadableString()),
|
||||
subtitle: Text(sourceInfo.artist),
|
||||
enabled: playlist.isFetching != true,
|
||||
selected: playlist.isFetching != true &&
|
||||
sourceInfo.id ==
|
||||
(playlist.activeTrack as SourcedTrack).sourceInfo.id,
|
||||
selectedTileColor: theme.popupMenuTheme.color,
|
||||
onTap: () {
|
||||
if (playlist.isFetching == false &&
|
||||
sourceInfo.id !=
|
||||
(playlist.activeTrack as SourcedTrack).sourceInfo.id) {
|
||||
playlistNotifier.swapSibling(sourceInfo);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
[playlist.isFetching, playlist.activeTrack, siblings],
|
||||
);
|
||||
|
||||
var mediaQuery = MediaQuery.of(context);
|
||||
return SafeArea(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 12.0,
|
||||
sigmaY: 12.0,
|
||||
),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
height: isSearching.value && mediaQuery.smAndDown
|
||||
? mediaQuery.size.height
|
||||
: mediaQuery.size.height * .6,
|
||||
margin: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
color: theme.scaffoldBackgroundColor.withOpacity(.3),
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: !isSearching.value
|
||||
? Text(
|
||||
context.l10n.alternative_track_sources,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
)
|
||||
: TextField(
|
||||
autofocus: true,
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.search,
|
||||
hintStyle: theme.textTheme.headlineSmall,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
automaticallyImplyLeading: false,
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 12.0,
|
||||
sigmaY: 12.0,
|
||||
),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
height: isSearching.value && mediaQuery.smAndDown
|
||||
? mediaQuery.size.height - 50
|
||||
: mediaQuery.size.height * .6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
color: theme.colorScheme.surfaceVariant.withOpacity(.5),
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
if (!isSearching.value)
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.search, size: 18),
|
||||
onPressed: () {
|
||||
isSearching.value = true;
|
||||
},
|
||||
)
|
||||
else ...[
|
||||
if (preferences.youtubeApiType == YoutubeApiType.piped)
|
||||
PopupMenuButton(
|
||||
icon: const Icon(SpotubeIcons.filter, size: 18),
|
||||
onSelected: (SearchMode mode) {
|
||||
searchMode.value = mode;
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: !isSearching.value
|
||||
? Text(
|
||||
context.l10n.alternative_track_sources,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
)
|
||||
: TextField(
|
||||
autofocus: true,
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.search,
|
||||
hintStyle: theme.textTheme.headlineSmall,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
if (!isSearching.value)
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.search, size: 18),
|
||||
onPressed: () {
|
||||
isSearching.value = true;
|
||||
},
|
||||
)
|
||||
else ...[
|
||||
if (preferences.audioSource == AudioSource.piped)
|
||||
PopupMenuButton(
|
||||
icon: const Icon(SpotubeIcons.filter, size: 18),
|
||||
onSelected: (SearchMode mode) {
|
||||
searchMode.value = mode;
|
||||
},
|
||||
initialValue: searchMode.value,
|
||||
itemBuilder: (context) => SearchMode.values
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
value: e,
|
||||
child: Text(e.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.close, size: 18),
|
||||
onPressed: () {
|
||||
isSearching.value = false;
|
||||
},
|
||||
initialValue: searchMode.value,
|
||||
itemBuilder: (context) => SearchMode.values
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
value: e,
|
||||
child: Text(e.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.close, size: 18),
|
||||
onPressed: () {
|
||||
isSearching.value = false;
|
||||
]
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (child, animation) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
child: switch (isSearching.value) {
|
||||
false => ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: siblings.length,
|
||||
itemBuilder: (context, index) =>
|
||||
itemBuilder(siblings[index]),
|
||||
),
|
||||
true => FutureBuilder(
|
||||
future: searchRequest,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(snapshot.error.toString()),
|
||||
);
|
||||
} else if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return InterScrollbar(
|
||||
controller: controller,
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (context, index) =>
|
||||
itemBuilder(snapshot.data![index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (child, animation) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
child: InterScrollbar(
|
||||
child: switch (isSearching.value) {
|
||||
false => ListView.builder(
|
||||
itemCount: siblings.length,
|
||||
itemBuilder: (context, index) =>
|
||||
itemBuilder(siblings[index]),
|
||||
),
|
||||
true => FutureBuilder(
|
||||
future: searchRequest,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(snapshot.error.toString()),
|
||||
);
|
||||
} else if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return InterScrollbar(
|
||||
child: ListView.builder(
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (context, index) =>
|
||||
itemBuilder(snapshot.data![index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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<List<TrackSimple>?>(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<List<Track>> fetchAllTracks() async {
|
||||
if (playlist.id == 'user-liked-tracks') {
|
||||
return await queryClient.fetchQuery(
|
||||
"user-liked-tracks",
|
||||
() => useQueries.playlist.likedTracks(spotify),
|
||||
) ??
|
||||
[];
|
||||
}
|
||||
|
||||
final query = queryClient.createInfiniteQuery<List<Track>, 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<Track> 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<Track> fetchedTracks = await fetchAllTracks();
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
@ -90,11 +105,8 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
updating.value = true;
|
||||
try {
|
||||
if (isPlaylistPlaying) return;
|
||||
List<Track> fetchedTracks = await queryBowl.fetchQuery(
|
||||
"playlist-tracks/${playlist.id}",
|
||||
() => useQueries.playlist.tracksOf(playlist.id!, spotify, ref),
|
||||
) ??
|
||||
[];
|
||||
|
||||
final fetchedTracks = await fetchAllTracks();
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
|
@ -14,12 +14,13 @@ import 'package:spotube/components/player/player_controls.dart';
|
||||
import 'package:spotube/components/player/volume_slider.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_brightness_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
|
@ -11,18 +11,19 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_brightness_value.dart';
|
||||
import 'package:spotube/hooks/use_sidebarx_controller.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class Sidebar extends HookConsumerWidget {
|
||||
final int selectedIndex;
|
||||
final int? selectedIndex;
|
||||
final void Function(int) onSelectedIndexChanged;
|
||||
final Widget child;
|
||||
|
||||
@ -57,7 +58,7 @@ class Sidebar extends HookConsumerWidget {
|
||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||
|
||||
final controller = useSidebarXController(
|
||||
selectedIndex: selectedIndex,
|
||||
selectedIndex: selectedIndex ?? 0,
|
||||
extended: mediaQuery.lgAndUp,
|
||||
);
|
||||
|
||||
@ -75,17 +76,21 @@ class Sidebar extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (controller.selectedIndex != selectedIndex) {
|
||||
controller.selectIndex(selectedIndex);
|
||||
if (controller.selectedIndex != selectedIndex && selectedIndex != null) {
|
||||
controller.selectIndex(selectedIndex!);
|
||||
}
|
||||
return null;
|
||||
}, [selectedIndex]);
|
||||
|
||||
useEffect(() {
|
||||
controller.addListener(() {
|
||||
void listener() {
|
||||
onSelectedIndexChanged(controller.selectedIndex);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
controller.addListener(listener);
|
||||
return () {
|
||||
controller.removeListener(listener);
|
||||
};
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() {
|
||||
|
@ -9,14 +9,15 @@ import 'package:spotube/collections/side_bar_tiles.dart';
|
||||
import 'package:spotube/components/root/sidebar.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_brightness_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
|
||||
final navigationPanelHeight = StateProvider<double>((ref) => 50);
|
||||
|
||||
class SpotubeNavigationBar extends HookConsumerWidget {
|
||||
final int selectedIndex;
|
||||
final int? selectedIndex;
|
||||
final void Function(int) onSelectedIndexChanged;
|
||||
|
||||
const SpotubeNavigationBar({
|
||||
@ -33,7 +34,7 @@ class SpotubeNavigationBar extends HookConsumerWidget {
|
||||
final layoutMode =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||
|
||||
final insideSelectedIndex = useState<int>(selectedIndex);
|
||||
final insideSelectedIndex = useState<int>(selectedIndex ?? 0);
|
||||
|
||||
final buttonColor = useBrightnessValue(
|
||||
theme.colorScheme.inversePrimary,
|
||||
@ -46,7 +47,9 @@ class SpotubeNavigationBar extends HookConsumerWidget {
|
||||
final panelHeight = ref.watch(navigationPanelHeight);
|
||||
|
||||
useEffect(() {
|
||||
insideSelectedIndex.value = selectedIndex;
|
||||
if (selectedIndex != null) {
|
||||
insideSelectedIndex.value = selectedIndex!;
|
||||
}
|
||||
return null;
|
||||
}, [selectedIndex]);
|
||||
|
||||
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:system_theme/system_theme.dart';
|
||||
|
||||
class SpotubeColor extends Color {
|
||||
@ -49,6 +49,7 @@ class ColorSchemePickerDialog extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final preferences = ref.watch(userPreferencesProvider);
|
||||
final preferencesNotifier = ref.watch(userPreferencesProvider.notifier);
|
||||
final scheme = preferences.accentColorScheme;
|
||||
final active = useState<String>(colorsMap.firstWhere(
|
||||
(element) {
|
||||
@ -57,7 +58,7 @@ class ColorSchemePickerDialog extends HookConsumerWidget {
|
||||
).name);
|
||||
|
||||
onOk() {
|
||||
preferences.setAccentColorScheme(
|
||||
preferencesNotifier.setAccentColorScheme(
|
||||
colorsMap.firstWhere(
|
||||
(element) {
|
||||
return element.name == active.value;
|
||||
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
|
||||
class PipedDownDialog extends HookConsumerWidget {
|
||||
const PipedDownDialog({Key? key}) : super(key: key);
|
||||
|
@ -1,4 +1,3 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@ -21,32 +20,21 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final userPlaylists = useQueries.playlist.ofMine(ref);
|
||||
|
||||
useEffect(() {
|
||||
final op = CancelableOperation.fromFuture(
|
||||
() async {
|
||||
while (userPlaylists.hasNextPage) {
|
||||
await userPlaylists.fetchNext();
|
||||
}
|
||||
}(),
|
||||
);
|
||||
|
||||
return () {
|
||||
op.cancel();
|
||||
};
|
||||
}, [userPlaylists.hasNextPage]);
|
||||
final userPlaylists = useQueries.playlist.ofMineAll(ref);
|
||||
|
||||
final me = useQueries.user.me(ref);
|
||||
|
||||
final filteredPlaylists = useMemoized(
|
||||
() => userPlaylists.pages
|
||||
.expand((page) => page.items?.toList() ?? <PlaylistSimple>[])
|
||||
.where(
|
||||
(playlist) =>
|
||||
playlist.owner?.id != null && playlist.owner!.id == me.data?.id,
|
||||
),
|
||||
[userPlaylists.pages, me.data?.id],
|
||||
() =>
|
||||
userPlaylists.data
|
||||
?.where(
|
||||
(playlist) =>
|
||||
playlist.owner?.id != null &&
|
||||
playlist.owner!.id == me.data?.id,
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
[userPlaylists.data, me.data?.id],
|
||||
);
|
||||
|
||||
final playlistsCheck = useState(<String, bool>{});
|
||||
@ -93,7 +81,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
|
||||
content: SizedBox(
|
||||
height: 300,
|
||||
width: 300,
|
||||
child: userPlaylists.hasNextPage
|
||||
child: userPlaylists.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -4,13 +4,15 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
|
||||
class ExpandableSearchField extends StatelessWidget {
|
||||
final ValueNotifier<bool> isFiltering;
|
||||
final bool isFiltering;
|
||||
final ValueChanged<bool> 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<bool> isFiltering;
|
||||
final bool isFiltering;
|
||||
final FocusNode searchFocus;
|
||||
final Widget icon;
|
||||
final ValueChanged<bool>? 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,94 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/album/album_card.dart';
|
||||
import 'package:spotube/components/artist/artist_card.dart';
|
||||
import 'package:spotube/components/playlist/playlist_card.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class HorizontalPlaybuttonCardView<T> extends HookWidget {
|
||||
final Widget title;
|
||||
final List<T> items;
|
||||
final VoidCallback onFetchMore;
|
||||
final bool isLoadingNextPage;
|
||||
final bool hasNextPage;
|
||||
|
||||
const HorizontalPlaybuttonCardView({
|
||||
required this.title,
|
||||
required this.items,
|
||||
required this.hasNextPage,
|
||||
required this.onFetchMore,
|
||||
required this.isLoadingNextPage,
|
||||
Key? key,
|
||||
}) : assert(
|
||||
items is List<PlaylistSimple> ||
|
||||
items is List<Album> ||
|
||||
items is List<Artist>,
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData(:textTheme) = Theme.of(context);
|
||||
final scrollController = useScrollController();
|
||||
final height = useBreakpointValue<double>(
|
||||
xs: 226,
|
||||
sm: 226,
|
||||
md: 236,
|
||||
others: 266,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DefaultTextStyle(
|
||||
style: textTheme.titleMedium!,
|
||||
child: title,
|
||||
),
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
},
|
||||
),
|
||||
child: InfiniteList(
|
||||
scrollController: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
itemCount: items.length,
|
||||
onFetchData: onFetchMore,
|
||||
loadingBuilder: (context) => const ShimmerPlaybuttonCard(),
|
||||
emptyBuilder: (context) =>
|
||||
const ShimmerPlaybuttonCard(count: 5),
|
||||
isLoading: isLoadingNextPage,
|
||||
hasReachedMax: !hasNextPage,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
|
||||
return switch (item.runtimeType) {
|
||||
PlaylistSimple => PlaylistCard(item as PlaylistSimple),
|
||||
Album => AlbumCard(item as Album),
|
||||
Artist => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: ArtistCard(item as Artist),
|
||||
),
|
||||
_ => const SizedBox.shrink(),
|
||||
};
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,29 +1,16 @@
|
||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
class InterScrollbar extends HookWidget {
|
||||
final Widget child;
|
||||
final ScrollController? controller;
|
||||
final bool? thumbVisibility;
|
||||
final bool? trackVisibility;
|
||||
final double? thickness;
|
||||
final Radius? radius;
|
||||
final bool Function(ScrollNotification)? notificationPredicate;
|
||||
final bool? interactive;
|
||||
final ScrollbarOrientation? scrollbarOrientation;
|
||||
final ScrollController controller;
|
||||
|
||||
const InterScrollbar({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.controller,
|
||||
this.thumbVisibility,
|
||||
this.trackVisibility,
|
||||
this.thickness,
|
||||
this.radius,
|
||||
this.notificationPredicate,
|
||||
this.interactive,
|
||||
this.scrollbarOrientation,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -32,38 +19,9 @@ class InterScrollbar extends HookWidget {
|
||||
|
||||
if (DesktopTools.platform.isDesktop) return child;
|
||||
|
||||
return ScrollbarTheme(
|
||||
data: theme.scrollbarTheme.copyWith(
|
||||
crossAxisMargin: 10,
|
||||
minThumbLength: 80,
|
||||
thickness: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.hovered) ||
|
||||
states.contains(MaterialState.dragged) ||
|
||||
states.contains(MaterialState.pressed)) {
|
||||
return 40;
|
||||
}
|
||||
return 20;
|
||||
}),
|
||||
radius: const Radius.circular(20),
|
||||
thumbColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.hovered) ||
|
||||
states.contains(MaterialState.dragged)) {
|
||||
return theme.colorScheme.onSurface.withOpacity(0.5);
|
||||
}
|
||||
return theme.colorScheme.onSurface.withOpacity(0.3);
|
||||
}),
|
||||
),
|
||||
child: Scrollbar(
|
||||
controller: controller,
|
||||
thumbVisibility: thumbVisibility,
|
||||
trackVisibility: trackVisibility,
|
||||
thickness: thickness,
|
||||
radius: radius,
|
||||
notificationPredicate: notificationPredicate,
|
||||
interactive: interactive ?? true,
|
||||
scrollbarOrientation: scrollbarOrientation,
|
||||
child: child,
|
||||
),
|
||||
return DraggableScrollbar.semicircle(
|
||||
controller: controller,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:titlebar_buttons/titlebar_buttons.dart';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show Platform, exit;
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:local_notifier/local_notifier.dart';
|
||||
|
||||
@ -17,7 +18,7 @@ final closeNotification = DesktopTools.createNotification(
|
||||
LocalNotificationAction(text: 'Close The App'),
|
||||
],
|
||||
)?..onClickAction = (value) {
|
||||
DesktopTools.window.close();
|
||||
exit(0);
|
||||
};
|
||||
|
||||
class PageWindowTitleBar extends StatefulHookConsumerWidget
|
||||
@ -113,7 +114,7 @@ class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
|
||||
Future<void> onClose() async {
|
||||
if (preferences.closeBehavior == CloseBehavior.close) {
|
||||
await DesktopTools.window.close();
|
||||
exit(0);
|
||||
} else {
|
||||
await DesktopTools.window.hide();
|
||||
await closeNotification?.show();
|
||||
|
@ -6,9 +6,8 @@ import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/hover_builder.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/use_brightness_value.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
|
||||
final htmlTagRegexp = RegExp(r"<[^>]*>", caseSensitive: true);
|
||||
|
||||
@ -59,53 +58,36 @@ class PlaybuttonCard extends HookWidget {
|
||||
);
|
||||
|
||||
final end = useBreakpointValue<double>(
|
||||
xs: 15,
|
||||
sm: 15,
|
||||
others: 20,
|
||||
);
|
||||
|
||||
final textsHeight = useState(
|
||||
(textsKey.currentContext?.findRenderObject() as RenderBox?)
|
||||
?.size
|
||||
.height ??
|
||||
110.00,
|
||||
xs: 10,
|
||||
sm: 10,
|
||||
others: 15,
|
||||
);
|
||||
|
||||
final cleanDescription = useDescription(description);
|
||||
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
textsHeight.value =
|
||||
(textsKey.currentContext?.findRenderObject() as RenderBox?)
|
||||
?.size
|
||||
.height ??
|
||||
textsHeight.value;
|
||||
});
|
||||
return null;
|
||||
}, [textsKey]);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: size),
|
||||
margin: margin,
|
||||
child: Material(
|
||||
color: Color.lerp(
|
||||
theme.colorScheme.surfaceVariant,
|
||||
theme.colorScheme.surface,
|
||||
useBrightnessValue(.9, .7),
|
||||
),
|
||||
borderRadius: radius,
|
||||
shadowColor: theme.colorScheme.background,
|
||||
elevation: 3,
|
||||
child: InkWell(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
onTap: onTap,
|
||||
borderRadius: radius,
|
||||
splashFactory: theme.splashFactory,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: size),
|
||||
margin: margin,
|
||||
child: Material(
|
||||
color: Color.lerp(
|
||||
theme.colorScheme.surfaceVariant,
|
||||
theme.colorScheme.surface,
|
||||
useBrightnessValue(.9, .7),
|
||||
),
|
||||
borderRadius: radius,
|
||||
shadowColor: theme.colorScheme.background,
|
||||
elevation: 3,
|
||||
child: InkWell(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
onTap: onTap,
|
||||
borderRadius: radius,
|
||||
splashFactory: theme.splashFactory,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
@ -121,115 +103,114 @@ class PlaybuttonCard extends HookWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
key: textsKey,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 15),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: AutoSizeText(
|
||||
title,
|
||||
maxLines: 1,
|
||||
minFontSize: theme.textTheme.bodyMedium!.fontSize!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (cleanDescription != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: AutoSizeText(
|
||||
cleanDescription,
|
||||
maxLines: 2,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
theme.colorScheme.onSurface.withOpacity(.5),
|
||||
if (isOwner)
|
||||
Positioned(
|
||||
top: 15,
|
||||
left: 15,
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: Alignment.centerLeft,
|
||||
curve: Curves.easeInExpo,
|
||||
child: HoverBuilder(builder: (context, isHovered) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueAccent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
SpotubeIcons.user,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
if (isHovered)
|
||||
Text(
|
||||
"Owned by you",
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: end,
|
||||
bottom: -15,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isPlaying)
|
||||
IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.background,
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
minimumSize: const Size.square(10),
|
||||
),
|
||||
icon: const Icon(SpotubeIcons.queueAdd),
|
||||
onPressed: isLoading ? null : onAddToQueuePressed,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
minimumSize: const Size.square(10),
|
||||
),
|
||||
icon: isLoading
|
||||
? SizedBox.fromSize(
|
||||
size: const Size.square(15),
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: isPlaying
|
||||
? const Icon(SpotubeIcons.pause)
|
||||
: const Icon(SpotubeIcons.play),
|
||||
onPressed: isLoading ? null : onPlaybuttonPressed,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOwner)
|
||||
Positioned(
|
||||
top: 15,
|
||||
left: 25,
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: Alignment.centerLeft,
|
||||
curve: Curves.easeInExpo,
|
||||
child: HoverBuilder(builder: (context, isHovered) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueAccent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
Column(
|
||||
key: textsKey,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 15),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: AutoSizeText(
|
||||
title,
|
||||
maxLines: 1,
|
||||
minFontSize: theme.textTheme.bodyMedium!.fontSize!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
SpotubeIcons.user,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
if (isHovered)
|
||||
Text(
|
||||
"Owned by you",
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
if (cleanDescription != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: AutoSizeText(
|
||||
cleanDescription,
|
||||
maxLines: 2,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
right: end,
|
||||
bottom: textsHeight.value - (kIsMobile ? 5 : 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isPlaying)
|
||||
IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.background,
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
minimumSize: const Size.square(10),
|
||||
),
|
||||
icon: const Icon(SpotubeIcons.queueAdd),
|
||||
onPressed: isLoading ? null : onAddToQueuePressed,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
minimumSize: const Size.square(10),
|
||||
),
|
||||
icon: isLoading
|
||||
? SizedBox.fromSize(
|
||||
size: const Size.square(15),
|
||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: isPlaying
|
||||
? const Icon(SpotubeIcons.pause)
|
||||
: const Icon(SpotubeIcons.play),
|
||||
onPressed: isLoading ? null : onPlaybuttonPressed,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:skeleton_text/skeleton_text.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
|
||||
import 'package:spotube/extensions/theme.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
|
||||
class ShimmerArtistProfile extends HookWidget {
|
||||
const ShimmerArtistProfile({Key? key}) : super(key: key);
|
||||
@ -50,7 +50,7 @@ class ShimmerArtistProfile extends HookWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Flexible(child: ShimmerTrackTile(noSliver: true)),
|
||||
const Flexible(child: ShimmerTrackTileGroup(noSliver: true)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
|
||||
import 'package:spotube/extensions/theme.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
|
||||
class ShimmerCategories extends HookWidget {
|
||||
const ShimmerCategories({Key? key}) : super(key: key);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
|
||||
class ShimmerPlaybuttonCardPainter extends CustomPainter {
|
||||
final Color background;
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import 'package:buttons_tabbar/buttons_tabbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/use_brightness_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
|
||||
|
@ -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<T> extends HookConsumerWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final String titleImage;
|
||||
final List<Widget> buttons;
|
||||
final AlbumSimple? album;
|
||||
final Query<List<TrackSimple>, 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -1,274 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
|
||||
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
|
||||
import 'package:spotube/hooks/use_palette_color.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class TrackCollectionView<T> extends HookConsumerWidget {
|
||||
final logger = getLogger(TrackCollectionView);
|
||||
final String id;
|
||||
final String title;
|
||||
final String? description;
|
||||
final Query<List<TrackSimple>, T> tracksSnapshot;
|
||||
final String titleImage;
|
||||
final PlayButtonState playingState;
|
||||
final Future<void> 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<Widget> 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<T>(
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
@ -1,367 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
||||
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
|
||||
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
|
||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||
import 'package:spotube/components/library/user_local_tracks.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
final trackCollectionSortState =
|
||||
StateProvider.family<SortBy, String>((ref, _) => SortBy.none);
|
||||
|
||||
class TracksTableView extends HookConsumerWidget {
|
||||
final Future<void> Function(Track currentTrack)? onTrackPlayButtonPressed;
|
||||
final List<Track> 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<List<String>>([]);
|
||||
final showCheck = useState<bool>(false);
|
||||
final sortBy = ref.watch(trackCollectionSortState(playlistId ?? ''));
|
||||
|
||||
final isFiltering = useState<bool>(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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -110,7 +110,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);
|
||||
});
|
@ -9,7 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/hover_builder.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/links/link_text.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_options.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_options.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
@ -91,7 +91,9 @@ class TrackTile extends HookConsumerWidget {
|
||||
isLoading.value = true;
|
||||
await onTap?.call();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (context.mounted) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongPress: onLongPress,
|
||||
@ -111,7 +113,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(
|
||||
'$index',
|
||||
'${(index ?? 0) + 1}',
|
||||
maxLines: 1,
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
@ -0,0 +1,130 @@
|
||||
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 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 trackViewState = ref.watch(trackViewProvider(uniqTracks));
|
||||
|
||||
final tracks = useMemoized(() {
|
||||
List<Track> 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
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<bool> 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: trackViewState.isSelecting
|
||||
? Checkbox(
|
||||
value: trackViewState.hasSelectedAll,
|
||||
onChanged: (checked) {
|
||||
if (checked == true) {
|
||||
trackViewState.selectAll();
|
||||
} else {
|
||||
trackViewState.deselectAll();
|
||||
}
|
||||
},
|
||||
)
|
||||
: constrains.mdAndUp
|
||||
? const SizedBox(width: 32)
|
||||
: const SizedBox(width: 16),
|
||||
),
|
||||
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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,131 @@
|
||||
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';
|
||||
import 'package:spotube/services/queries/queries.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;
|
||||
|
||||
final userPlaylists = useQueries.playlist.ofMineAll(ref);
|
||||
|
||||
final isUserPlaylist =
|
||||
userPlaylists.data?.any((e) => e.id == props.collectionId) ?? false;
|
||||
|
||||
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(
|
||||
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),
|
||||
),
|
||||
),
|
||||
if (!isUserPlaylist)
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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],
|
||||
);
|
||||
}
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
44
lib/components/shared/tracks_view/track_view.dart
Normal file
44
lib/components/shared/tracks_view/track_view.dart
Normal file
@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||
|
||||
class TrackView extends HookConsumerWidget {
|
||||
const TrackView({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final props = InheritedTrackView.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: DesktopTools.platform.isDesktop
|
||||
? const PageWindowTitleBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
leadingWidth: 400,
|
||||
leading: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BackButton(color: Colors.white),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const TrackViewFlexHeader(),
|
||||
SliverAnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: props.tracks.isEmpty
|
||||
? const ShimmerTrackTileGroup()
|
||||
: const TrackViewBodySection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
102
lib/components/shared/tracks_view/track_view_props.dart
Normal file
102
lib/components/shared/tracks_view/track_view_props.dart
Normal file
@ -0,0 +1,102 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class PaginationProps {
|
||||
final bool hasNextPage;
|
||||
final bool isLoading;
|
||||
final VoidCallback onFetchMore;
|
||||
final Future<List<Track>> Function() onFetchAll;
|
||||
|
||||
const PaginationProps({
|
||||
required this.hasNextPage,
|
||||
required this.isLoading,
|
||||
required this.onFetchMore,
|
||||
required this.onFetchAll,
|
||||
});
|
||||
|
||||
factory PaginationProps.fromQuery(
|
||||
InfiniteQuery<List<Track>, dynamic, int> query, {
|
||||
required Future<List<Track>> Function() onFetchAll,
|
||||
}) {
|
||||
return PaginationProps(
|
||||
hasNextPage: query.hasNextPage,
|
||||
isLoading: query.isLoadingNextPage,
|
||||
onFetchMore: query.fetchNext,
|
||||
onFetchAll: onFetchAll,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
operator ==(Object other) {
|
||||
return other is PaginationProps &&
|
||||
other.hasNextPage == hasNextPage &&
|
||||
other.isLoading == isLoading &&
|
||||
other.onFetchMore == onFetchMore &&
|
||||
other.onFetchAll == onFetchAll;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
super.hashCode ^
|
||||
hasNextPage.hashCode ^
|
||||
isLoading.hashCode ^
|
||||
onFetchMore.hashCode ^
|
||||
onFetchAll.hashCode;
|
||||
}
|
||||
|
||||
class InheritedTrackView extends InheritedWidget {
|
||||
final String collectionId;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String image;
|
||||
final String routePath;
|
||||
final List<Track> 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<InheritedTrackView>();
|
||||
if (widget == null) {
|
||||
throw Exception(
|
||||
'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]',
|
||||
);
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
}
|
64
lib/components/shared/tracks_view/track_view_provider.dart
Normal file
64
lib/components/shared/tracks_view/track_view_provider.dart
Normal file
@ -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<Track> tracks;
|
||||
List<String> 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<Track> 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<TrackViewNotifier, List<Track>>((ref, tracks) {
|
||||
return TrackViewNotifier(tracks);
|
||||
});
|
28
lib/extensions/color.dart
Normal file
28
lib/extensions/color.dart
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -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 =>
|
||||
|
34
lib/extensions/infinite_query.dart
Normal file
34
lib/extensions/infinite_query.dart
Normal file
@ -0,0 +1,34 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
extension FetchAllTracks on InfiniteQuery<List<Track>, dynamic, int> {
|
||||
Future<List<Track>> fetchAllTracks({
|
||||
required Future<List<Track>> 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<int, List<Track>> 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();
|
||||
}
|
||||
}
|
11
lib/extensions/string.dart
Normal file
11
lib/extensions/string.dart
Normal file
@ -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!);
|
||||
}
|
@ -4,26 +4,29 @@ import 'package:spotube/extensions/artist_simple.dart';
|
||||
|
||||
extension TrackJson on Track {
|
||||
Map<String, dynamic> toJson() {
|
||||
return TrackJson.trackToJson(this);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> trackToJson(Track track) {
|
||||
return {
|
||||
"album": album?.toJson(),
|
||||
"artists": artists?.map((artist) => artist.toJson()).toList(),
|
||||
"availableMarkets": availableMarkets?.map((e) => e.name).toList(),
|
||||
"discNumber": discNumber,
|
||||
"duration": duration.toString(),
|
||||
"durationMs": durationMs,
|
||||
"explicit": explicit,
|
||||
// "externalIds": externalIds,
|
||||
// "externalUrls": externalUrls,
|
||||
"href": href,
|
||||
"id": id,
|
||||
"isPlayable": isPlayable,
|
||||
// "linkedFrom": linkedFrom,
|
||||
"name": name,
|
||||
"popularity": popularity,
|
||||
"previewUrl": previewUrl,
|
||||
"trackNumber": trackNumber,
|
||||
"type": type,
|
||||
"uri": uri,
|
||||
"album": track.album?.toJson(),
|
||||
"artists": track.artists?.map((artist) => artist.toJson()).toList(),
|
||||
"available_markets": track.availableMarkets?.map((e) => e.name).toList(),
|
||||
"disc_number": track.discNumber,
|
||||
"duration_ms": track.durationMs,
|
||||
"explicit": track.explicit,
|
||||
// "external_ids"track.: externalIds,
|
||||
// "external_urls"track.: externalUrls,
|
||||
"href": track.href,
|
||||
"id": track.id,
|
||||
"is_playable": track.isPlayable,
|
||||
// "linked_from"track.: linkedFrom,
|
||||
"name": track.name,
|
||||
"popularity": track.popularity,
|
||||
"preview_rrl": track.previewUrl,
|
||||
"track_number": track.trackNumber,
|
||||
"type": track.type,
|
||||
"uri": track.uri,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:disable_battery_optimization/disable_battery_optimization.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotube/hooks/use_async_effect.dart';
|
||||
import 'package:spotube/hooks/utils/use_async_effect.dart';
|
||||
|
||||
bool _asked = false;
|
||||
void useDisableBatteryOptimizations() {
|
38
lib/hooks/configurators/use_get_storage_perms.dart
Normal file
38
lib/hooks/configurators/use_get_storage_perms.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:spotube/components/library/user_local_tracks.dart';
|
||||
import 'package:spotube/hooks/utils/use_async_effect.dart';
|
||||
|
||||
void useGetStoragePermissions(WidgetRef ref) {
|
||||
final isMounted = useIsMounted();
|
||||
|
||||
useAsyncEffect(
|
||||
() async {
|
||||
if (!DesktopTools.platform.isMobile) return;
|
||||
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
|
||||
final hasNoStoragePerm = androidInfo.version.sdkInt < 33 &&
|
||||
!await Permission.storage.isGranted &&
|
||||
!await Permission.storage.isLimited;
|
||||
|
||||
final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 &&
|
||||
!await Permission.audio.isGranted &&
|
||||
!await Permission.audio.isLimited;
|
||||
|
||||
if (hasNoStoragePerm) {
|
||||
await Permission.storage.request();
|
||||
if (isMounted()) ref.refresh(localTracksProvider);
|
||||
}
|
||||
if (hasNoAudioPerm) {
|
||||
await Permission.audio.request();
|
||||
if (isMounted()) ref.refresh(localTracksProvider);
|
||||
}
|
||||
},
|
||||
null,
|
||||
[],
|
||||
);
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@ -5,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/intents.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
|
||||
void useInitSysTray(WidgetRef ref) {
|
||||
final context = useContext();
|
||||
@ -70,7 +72,7 @@ void useInitSysTray(WidgetRef ref) {
|
||||
label: "Quit",
|
||||
name: "quit",
|
||||
onClicked: (item) async {
|
||||
await DesktopTools.window.close();
|
||||
exit(0);
|
||||
},
|
||||
),
|
||||
],
|
@ -8,8 +8,8 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:spotube/collections/env.dart';
|
||||
|
||||
import 'package:spotube/components/shared/links/anchor_button.dart';
|
||||
import 'package:spotube/hooks/use_package_info.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/hooks/controllers/use_package_info.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
@ -1,18 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
bool? useIsCurrentRoute([String matcher = "/"]) {
|
||||
final isCurrentRoute = useState<bool?>(null);
|
||||
final context = useContext();
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timer) {
|
||||
final isCurrent = GoRouterState.of(context).matchedLocation == matcher;
|
||||
if (isCurrent != isCurrentRoute.value) {
|
||||
isCurrentRoute.value = isCurrent;
|
||||
}
|
||||
});
|
||||
return null;
|
||||
});
|
||||
return isCurrentRoute.value;
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
SharedPreferences? useSharedPreferences() {
|
||||
final future = useMemoized(SharedPreferences.getInstance);
|
||||
final snapshot = useFuture(future, initialData: null);
|
||||
|
||||
return snapshot.data;
|
||||
}
|
@ -263,8 +263,8 @@
|
||||
"use_system_title_bar": "Use system title bar",
|
||||
"crunching_results": "Crunching results...",
|
||||
"search_to_get_results": "Search to get results",
|
||||
"use_amoled_mode": "Use AMOLED mode",
|
||||
"pitch_dark_theme": "Pitch black dart theme",
|
||||
"use_amoled_mode": "Pitch black dark theme",
|
||||
"pitch_dark_theme": "AMOLED Mode",
|
||||
"normalize_audio": "Normalize audio",
|
||||
"change_cover": "Change cover",
|
||||
"add_cover": "Add cover",
|
||||
|
283
lib/l10n/app_tr.arb
Normal file
283
lib/l10n/app_tr.arb
Normal file
@ -0,0 +1,283 @@
|
||||
{
|
||||
"guest": "Misafir",
|
||||
"browse": "Gözat",
|
||||
"search": "Ara",
|
||||
"library": "Kütüphane",
|
||||
"lyrics": "Sözler",
|
||||
"settings": "Ayarlar",
|
||||
"genre_categories_filter": "Kategorileri veya türleri filtreleyin...",
|
||||
"genre": "Tür",
|
||||
"personalized": "Kişiselleştirilmiş",
|
||||
"featured": "Öne Çıkanlar",
|
||||
"new_releases": "Yeni Çıkanlar",
|
||||
"songs": "Şarkılar",
|
||||
"playing_track": "Oynatılıyor {track}",
|
||||
"queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parçaları kaldırılacaktır\nDevam etmek istiyor musunuz?",
|
||||
"load_more": "Daha fazlasını yükle",
|
||||
"playlists": "Çalma Listeleri",
|
||||
"artists": "Sanatçılar",
|
||||
"albums": "Albümler",
|
||||
"tracks": "Parçalar",
|
||||
"downloads": "İndirmeler",
|
||||
"filter_playlists": "Çalma listelerinizi filtreleyin...",
|
||||
"liked_tracks": "Beğenilen Parçalar",
|
||||
"liked_tracks_description": "Beğendiğiniz tüm parçalar",
|
||||
"create_playlist": "Çalma Listesi Oluştur",
|
||||
"create_a_playlist": "Bir çalma listesi oluştur",
|
||||
"update_playlist": "Çalma listesini güncelle",
|
||||
"create": "Oluştur",
|
||||
"cancel": "İptal",
|
||||
"update": "Güncelle",
|
||||
"playlist_name": "Çalma Listesi Adı",
|
||||
"name_of_playlist": "Çalma listesi adı",
|
||||
"description": "Açıklama",
|
||||
"public": "Halka açık",
|
||||
"collaborative": "İşbirliği",
|
||||
"search_local_tracks": "Yerel parçaları arayın...",
|
||||
"play": "Oynat",
|
||||
"delete": "Sil",
|
||||
"none": "Hiçbiri",
|
||||
"sort_a_z": "A'dan Z'ye sırala",
|
||||
"sort_z_a": "Z'dan A'ye sırala",
|
||||
"sort_artist": "Sanatçıya Göre Sırala",
|
||||
"sort_album": "Albüme Göre Sırala",
|
||||
"sort_tracks": "Parçaları Sırala",
|
||||
"currently_downloading": "Şu Anda İndiriliyor ({tracks_length})",
|
||||
"cancel_all": "Tümünü İptal Et",
|
||||
"filter_artist": "Sanatçıları filtrele...",
|
||||
"followers": "{followers} Takipçiler",
|
||||
"add_artist_to_blacklist": "Sanatçıyı kara listeye ekle",
|
||||
"top_tracks": "En İyi Parçalar",
|
||||
"fans_also_like": "Hayranlar ayrıca şunları beğendi",
|
||||
"loading": "Yükleniyor...",
|
||||
"artist": "Sanatçı",
|
||||
"blacklisted": "Kara Listede",
|
||||
"following": "Takip Ediliyor",
|
||||
"follow": "Takip Et",
|
||||
"artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı",
|
||||
"added_to_queue": "Kuyruğa {tracks} parçaları eklendi",
|
||||
"filter_albums": "Albümleri filtrele...",
|
||||
"synced": "Eşitlendi",
|
||||
"plain": "Sade",
|
||||
"shuffle": "Karıştır",
|
||||
"search_tracks": "Parça ara...",
|
||||
"released": "Yayınlandı",
|
||||
"error": "Hata {error}",
|
||||
"title": "Başlık",
|
||||
"time": "Zaman",
|
||||
"more_actions": "Daha fazla işlem",
|
||||
"download_count": "İndir ({count})",
|
||||
"add_count_to_playlist": "Çalma Listesine ({count}) Ekle",
|
||||
"add_count_to_queue": "Sıraya ({count}) ekle",
|
||||
"play_count_next": "Oynat ({count}) sonraki",
|
||||
"album": "Albüm",
|
||||
"copied_to_clipboard": "Panoya {data} kopyalandı",
|
||||
"add_to_following_playlists": "Aşağıdaki Çalma Listelerine {track} ekle",
|
||||
"add": "Ekle",
|
||||
"added_track_to_queue": "Sıraya {track} eklendi",
|
||||
"add_to_queue": "Kuyruğa ekle",
|
||||
"track_will_play_next": "{track} sonraki çalacak",
|
||||
"play_next": "Sıradaki",
|
||||
"removed_track_from_queue": "Sıradan {track} kaldırıldı",
|
||||
"remove_from_queue": "Kuyruktan çıkar",
|
||||
"remove_from_favorites": "Favorilerden kaldır",
|
||||
"save_as_favorite": "Favori olarak kaydet",
|
||||
"add_to_playlist": "Çalma listesine ekle",
|
||||
"remove_from_playlist": "Çalma listesinden kaldır",
|
||||
"add_to_blacklist": "Kara listeye ekle",
|
||||
"remove_from_blacklist": "Kara listeden çıkar",
|
||||
"share": "Paylaş",
|
||||
"mini_player": "Mini Oynatıcı",
|
||||
"slide_to_seek": "İleri veya geri arama yapmak için kaydırın",
|
||||
"shuffle_playlist": "Çalma listesini karıştır",
|
||||
"unshuffle_playlist": "Karışık çalma listesi",
|
||||
"previous_track": "Önceki parça",
|
||||
"next_track": "Sonraki parça",
|
||||
"pause_playback": "Çalmayı Duraklat",
|
||||
"resume_playback": "Çalmaya Devam Et",
|
||||
"loop_track": "Döngü parçası",
|
||||
"repeat_playlist": "Çalma listesini tekrarla",
|
||||
"queue": "Sıra",
|
||||
"alternative_track_sources": "Alternatif parça kaynakları",
|
||||
"download_track": "Parçayı indir",
|
||||
"tracks_in_queue": "{tracks} sıradaki parçalar",
|
||||
"clear_all": "Tümünü temizle",
|
||||
"show_hide_ui_on_hover": "Üzerine gelindiğinde kullanıcı arayüzünü göster/gizle",
|
||||
"always_on_top": "Her zaman en üstte",
|
||||
"exit_mini_player": "Mini oynatıcıdan çık",
|
||||
"download_location": "İndirme konumu",
|
||||
"account": "Hesap",
|
||||
"login_with_spotify": "Spotify hesabınız ile giriş yapın",
|
||||
"connect_with_spotify": "Spotify ile bağlantı kurun",
|
||||
"logout": "Çıkış Yap",
|
||||
"logout_of_this_account": "Bu hesaptan çıkış yap",
|
||||
"language_region": "Dil & Bölge",
|
||||
"language": "Dil",
|
||||
"system_default": "Sistem Varsayılanı",
|
||||
"market_place_region": "Mevcut Bölge",
|
||||
"recommendation_country": "Tavsiye Edilen Ülke",
|
||||
"appearance": "Görünüm",
|
||||
"layout_mode": "Düzen Modu",
|
||||
"override_layout_settings": "Duyarlı düzen modu ayarlarını geçersiz kıl",
|
||||
"adaptive": "Uyarlanabilir",
|
||||
"compact": "Sıkıştırılmış",
|
||||
"extended": "Genişletilmiş",
|
||||
"theme": "Tema",
|
||||
"dark": "Karanlık",
|
||||
"light": "Aydınlık",
|
||||
"system": "Sistem",
|
||||
"accent_color": "Vurgu Rengi",
|
||||
"sync_album_color": "Albüm rengini eşitle",
|
||||
"sync_album_color_description": "Albüm resminin baskın rengini vurgu rengi olarak kullanır",
|
||||
"playback": "Çalma",
|
||||
"audio_quality": "Ses Kalitesi",
|
||||
"high": "Yüksek",
|
||||
"low": "Düşük",
|
||||
"pre_download_play": "Önceden indir ve oynat",
|
||||
"pre_download_play_description": "Ses akışı yerine, baytları indirin ve oynatın (Daha yüksek bant genişliği kullanıcıları için önerilir)",
|
||||
"skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)",
|
||||
"blacklist_description": "Kara listeye alınan parçalar ve sanatçılar",
|
||||
"wait_for_download_to_finish": "Lütfen mevcut indirme işleminin bitmesini bekleyin",
|
||||
"desktop": "Masaüstü",
|
||||
"close_behavior": "Yakın Davranış",
|
||||
"close": "Kapat",
|
||||
"minimize_to_tray": "Tepsiye küçült",
|
||||
"show_tray_icon": "Sistem tepsisi simgesini göster",
|
||||
"about": "Hakkında",
|
||||
"u_love_spotube": "Spotube'u sevdiğinizi biliyoruz",
|
||||
"check_for_updates": "Güncellemeleri kontrol et",
|
||||
"about_spotube": "Spotube Hakkında",
|
||||
"blacklist": "Kara Liste",
|
||||
"please_sponsor": "Lütfen Sponsor Olun/Bağış Yapın",
|
||||
"spotube_description": "Spotube, hafif, platformlar arası, herkesin kullanabileceği ücretsiz bir Spotify istemcisidir.",
|
||||
"version": "Sürüm",
|
||||
"build_number": "Derleme Numarası",
|
||||
"founder": "Kurucu",
|
||||
"repository": "Depo",
|
||||
"bug_issues": "Hata + Sorunlar",
|
||||
"made_with": "❤️ ile Bangladesh🇧🇩 adresinde yapılmıştır.",
|
||||
"kingkor_roy_tirtho": "Kingkor Roy Tirtho",
|
||||
"copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
|
||||
"license": "Lisans",
|
||||
"add_spotify_credentials": "Başlamak için spotify bilgilerinizi ekleyin",
|
||||
"credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, bilgileriniz toplanmayacak veya kimseyle paylaşılmayacak",
|
||||
"know_how_to_login": "Nasıl yapılacağını bilmiyor musunuz?",
|
||||
"follow_step_by_step_guide": "Adım Adım kılavuzu takip edin",
|
||||
"spotify_cookie": "Spotify {name} Çerez",
|
||||
"cookie_name_cookie": "{name} Çerez",
|
||||
"fill_in_all_fields": "Lütfen tüm alanları doldurun",
|
||||
"submit": "Gönder",
|
||||
"exit": "Çık",
|
||||
"previous": "Önceki",
|
||||
"next": "Sonraki",
|
||||
"done": "Bitti",
|
||||
"step_1": "1. Adım",
|
||||
"first_go_to": "İlk önce şu adrese gidin",
|
||||
"login_if_not_logged_in": "ve oturum açmadıysanız Giriş Yapın/Kaydolun",
|
||||
"step_2": "2. Adım",
|
||||
"step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı devtools.\n2'yi açmak için F12'ye basın veya Fare Sağ Tıklaması > İncele'ye basın. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin",
|
||||
"step_3": "3. Adım",
|
||||
"step_3_steps": "\"sp_dc\" ve \"sp_key\" (veya sp_gaid) Çerezlerinin değerlerini kopyalayın",
|
||||
"success_emoji": "Başarılı🥳",
|
||||
"success_message": "Şimdi Spotify hesabınızla başarılı bir şekilde oturum açtınız. İyi iş, dostum!",
|
||||
"step_4": "4. Adım",
|
||||
"step_4_steps": "Kopyalanan \"sp_dc\" ve \"sp_key\" (veya sp_gaid) değerlerini ilgili alanlara yapıştırın",
|
||||
"something_went_wrong": "Bir şeyler ters gitti",
|
||||
"piped_instance": "Piped Sunucu Örneği",
|
||||
"piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği",
|
||||
"piped_warning": "Bazıları iyi çalışmayabilir. Bu yüzden riski size ait olmak üzere kullanın",
|
||||
"generate_playlist": "Çalma Listesi Oluştur",
|
||||
"track_exists": "Track {track} zaten mevcut",
|
||||
"replace_downloaded_tracks": "İndirilen tüm parçaları değiştir",
|
||||
"skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla",
|
||||
"do_you_want_to_replace": "Mevcut parçayı değiştirmek mi istiyorsunuz?",
|
||||
"replace": "Değiştir",
|
||||
"skip": "Atla",
|
||||
"select_up_to_count_type": "En fazla {count} {type} seçin",
|
||||
"select_genres": "Tür Seç",
|
||||
"add_genres": "Tür Ekle",
|
||||
"country": "Ülke",
|
||||
"number_of_tracks_generate": "Oluşturulacak parça sayısı",
|
||||
"acousticness": "Akustiklik",
|
||||
"danceability": "Dansedilebilirlik",
|
||||
"energy": "Enerji",
|
||||
"instrumentalness": "Enstrümansallık",
|
||||
"liveness": "Canlılık",
|
||||
"loudness": "Yükseklik",
|
||||
"speechiness": "Konuşkanlık",
|
||||
"valence": "Değerlilik",
|
||||
"popularity": "Popülerlik",
|
||||
"key": "Anahtar",
|
||||
"duration": "Süre (sn)",
|
||||
"tempo": "Tempo (BPM)",
|
||||
"mode": "Mod",
|
||||
"time_signature": "Zaman İmzası",
|
||||
"short": "Kısa",
|
||||
"medium": "Orta",
|
||||
"long": "Uzun",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"target": "Hedef",
|
||||
"moderate": "Orta",
|
||||
"deselect_all": "Tüm Seçimleri Kaldır",
|
||||
"select_all": "Tümünü Seç",
|
||||
"are_you_sure": "Emin misiniz?",
|
||||
"generating_playlist": "Özel çalma listenizi oluşturun...",
|
||||
"selected_count_tracks": "Seçilen {count} parçalar",
|
||||
"download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapmış ve yaratıcı Müzik toplumuna zarar vermiş olursunuz. Umarım bunun farkındasınızdır. Her zaman, Sanatçıların sıkı çalışmalarına saygı duymayı ve desteklemeyi deneyin",
|
||||
"download_ip_ban_warning": "Bu arada, normalden fazla indirme isteği nedeniyle IP adresiniz YouTube'da engellenebilir. IP engeli, o IP cihazından en az 2-3 ay boyunca YouTube'u (giriş yapmış olsanız bile) kullanamayacağınız anlamına gelir. Ve Spotube böyle bir durumda herhangi bir sorumluluk kabul etmez",
|
||||
"by_clicking_accept_terms": "'Kabul et' seçeneğine tıklayarak aşağıdaki şartları kabul etmiş olursunuz:",
|
||||
"download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben malım.",
|
||||
"download_agreement_2": "Sanatçıları elimden geldiğince destekleyeceğim ve bunu sadece sanatlarını satın alacak param olmadığı için yapıyorum",
|
||||
"download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut eylemimin neden olduğu herhangi bir kazadan Spotube'u veya sahiplerini/dağıtıcılarını sorumlu tutmuyorum",
|
||||
"decline": "Reddet",
|
||||
"accept": "Kabul et",
|
||||
"details": "Detaylar",
|
||||
"youtube": "YouTube",
|
||||
"channel": "Kanal",
|
||||
"likes": "Beğeniler",
|
||||
"dislikes": "Beğenmemeler",
|
||||
"views": "İzlenmeler",
|
||||
"streamUrl": "Yayın Bağlantısı",
|
||||
"stop": "Dur",
|
||||
"sort_newest": "En yeni eklenene göre sırala",
|
||||
"sort_oldest": "En eski eklenene göre sırala",
|
||||
"sleep_timer": "Uyku Zamanlayıcısı",
|
||||
"mins": "{minutes} Dakikalar",
|
||||
"hours": "{hours} Saat",
|
||||
"hour": "{hours} Saatler",
|
||||
"custom_hours": "Özel Saatler",
|
||||
"logs": "Günlükler",
|
||||
"developers": "Geliştiriciler",
|
||||
"not_logged_in": "Giriş yapmadınız",
|
||||
"search_mode": "Arama Modu",
|
||||
"youtube_api_type": "API Türü",
|
||||
"ok": "Tamam",
|
||||
"failed_to_encrypt": "Şifreleme başarısız oldu",
|
||||
"encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.",
|
||||
"querying_info": "Bilgi sorgulanıyor...",
|
||||
"piped_api_down": "Piped API kapalı",
|
||||
"piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nYa örneği değiştirin ya da 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun",
|
||||
"you_are_offline": "Şu anda çevrimdışısınız",
|
||||
"connection_restored": "İnternet bağlantınız yeniden kuruldu",
|
||||
"use_system_title_bar": "Sistem başlık çubuğunu kullan",
|
||||
"crunching_results": "Sonuçlar kırılıyor...",
|
||||
"search_to_get_results": "Sonuç almak için arama yap",
|
||||
"use_amoled_mode": "AMOLED modunu kullan",
|
||||
"pitch_dark_theme": "Zifiri siyah dart teması",
|
||||
"normalize_audio": "Sesi normalleştir",
|
||||
"change_cover": "Kapağı değiştir",
|
||||
"add_cover": "Kapak ekle",
|
||||
"restore_defaults": "Varsayılanları geri yükle",
|
||||
"download_music_codec": "Müzik codec bileşenini indirin",
|
||||
"streaming_music_codec": "Müzik akışı codec bileşeni",
|
||||
"login_with_lastfm": "Last.fm ile giriş yap",
|
||||
"connect": "Bağlan",
|
||||
"disconnect_lastfm": "Last.fm bağlantısını kes",
|
||||
"disconnect": "Bağlantıyı Kes",
|
||||
"username": "Kullanıcı Adı",
|
||||
"password": "Şifre",
|
||||
"login": "Giriş Yap",
|
||||
"login_with_your_lastfm": "Last.fm hesabınız ile giriş yapın",
|
||||
"scrobble_to_lastfm": "Last.fm için Scrobble"
|
||||
}
|
@ -6,24 +6,26 @@
|
||||
/// iceyear@github => Simplified Chinese
|
||||
/// TexturedPolak@github => Polish
|
||||
/// yuri-val@github => Ukrainian
|
||||
/// mdksec@github => Turkish
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class L10n {
|
||||
static final all = [
|
||||
const Locale('en'),
|
||||
const Locale('ar', 'SA'),
|
||||
const Locale('bn', 'BD'),
|
||||
const Locale('de', 'GE'),
|
||||
const Locale('ca', 'AD'),
|
||||
const Locale('de', 'GE'),
|
||||
const Locale('es', 'ES'),
|
||||
const Locale("fa", "IR"),
|
||||
const Locale('fr', 'FR'),
|
||||
const Locale('hi', 'IN'),
|
||||
const Locale('ja', 'JP'),
|
||||
const Locale('zh', 'CN'),
|
||||
const Locale('pl', 'PL'),
|
||||
const Locale('ru', 'RU'),
|
||||
const Locale('pt', 'PT'),
|
||||
const Locale('ru', 'RU'),
|
||||
const Locale('uk', 'UA'),
|
||||
const Locale('ar', 'SA'),
|
||||
const Locale('tr', 'TR'),
|
||||
const Locale('zh', 'CN'),
|
||||
];
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:catcher_2/catcher_2.dart';
|
||||
import 'package:dart_discord_rpc/dart_discord_rpc.dart';
|
||||
import 'package:device_preview/device_preview.dart';
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@ -14,13 +15,14 @@ import 'package:metadata_god/metadata_god.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotube/collections/routes.dart';
|
||||
import 'package:spotube/collections/intents.dart';
|
||||
import 'package:spotube/hooks/use_disable_battery_optimizations.dart';
|
||||
import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart';
|
||||
import 'package:spotube/hooks/configurators/use_get_storage_perms.dart';
|
||||
import 'package:spotube/l10n/l10n.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/models/matched_track.dart';
|
||||
import 'package:spotube/models/skip_segment.dart';
|
||||
import 'package:spotube/models/source_match.dart';
|
||||
import 'package:spotube/provider/palette_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/cli/cli.dart';
|
||||
import 'package:spotube/services/connectivity_adapter.dart';
|
||||
@ -28,7 +30,7 @@ import 'package:spotube/themes/theme.dart';
|
||||
import 'package:spotube/utils/persisted_state_notifier.dart';
|
||||
import 'package:system_theme/system_theme.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotube/hooks/use_init_sys_tray.dart';
|
||||
import 'package:spotube/hooks/configurators/use_init_sys_tray.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||
@ -62,6 +64,10 @@ Future<void> main(List<String> rawArgs) async {
|
||||
MetadataGod.initialize();
|
||||
}
|
||||
|
||||
if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) {
|
||||
DiscordRPC.initialize();
|
||||
}
|
||||
|
||||
final hiveCacheDir =
|
||||
kIsWeb ? null : (await getApplicationSupportDirectory()).path;
|
||||
|
||||
@ -70,16 +76,18 @@ Future<void> main(List<String> 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>(
|
||||
MatchedTrack.boxName,
|
||||
await Hive.openLazyBox<SourceMatch>(
|
||||
SourceMatch.boxName,
|
||||
path: hiveCacheDir,
|
||||
);
|
||||
await Hive.openLazyBox(
|
||||
@ -181,6 +189,7 @@ class SpotubeState extends ConsumerState<Spotube> {
|
||||
}, []);
|
||||
|
||||
useDisableBatteryOptimizations();
|
||||
useGetStoragePermissions(ref);
|
||||
|
||||
final lightTheme = useMemoized(
|
||||
() => theme(paletteColor ?? accentMaterialColor, Brightness.light, false),
|
||||
|
@ -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<Track>? _tempTrack;
|
||||
@ -18,13 +19,13 @@ class CurrentPlaylist {
|
||||
this.isLocal = false,
|
||||
});
|
||||
|
||||
static CurrentPlaylist fromJson(Map<String, dynamic> map) {
|
||||
static CurrentPlaylist fromJson(Map<String, dynamic> map, Ref ref) {
|
||||
return CurrentPlaylist(
|
||||
id: map["id"],
|
||||
tracks: List.castFrom<dynamic, Track>(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,
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/extensions/album_simple.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/track.dart';
|
||||
|
||||
class LocalTrack extends Track {
|
||||
final String path;
|
||||
@ -38,22 +37,7 @@ class LocalTrack extends Track {
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"album": album?.toJson(),
|
||||
"artists": artists?.map((artist) => artist.toJson()).toList(),
|
||||
"availableMarkets": availableMarkets?.map((m) => m.name),
|
||||
"discNumber": discNumber,
|
||||
"duration": duration.toString(),
|
||||
"durationMs": durationMs,
|
||||
"explicit": explicit,
|
||||
"href": href,
|
||||
"id": id,
|
||||
"isPlayable": isPlayable,
|
||||
"name": name,
|
||||
"popularity": popularity,
|
||||
"previewUrl": previewUrl,
|
||||
"trackNumber": trackNumber,
|
||||
"type": type,
|
||||
"uri": uri,
|
||||
...TrackJson.trackToJson(this),
|
||||
'path': path,
|
||||
};
|
||||
}
|
||||
|
@ -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<MatchedTrack> get box => Hive.lazyBox<MatchedTrack>(boxName);
|
||||
|
||||
MatchedTrack({
|
||||
required this.youtubeId,
|
||||
required this.spotifyId,
|
||||
required this.searchMode,
|
||||
this.id,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory MatchedTrack.fromJson(Map<String, dynamic> 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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'matched_track.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class MatchedTrackAdapter extends TypeAdapter<MatchedTrack> {
|
||||
@override
|
||||
final int typeId = 1;
|
||||
|
||||
@override
|
||||
MatchedTrack read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
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<SearchMode> {
|
||||
@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;
|
||||
}
|
54
lib/models/source_match.dart
Normal file
54
lib/models/source_match.dart
Normal file
@ -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<String, dynamic> json) =>
|
||||
_$SourceMatchFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SourceMatchToJson(this);
|
||||
|
||||
static String version = 'v1';
|
||||
static final boxName = "oss.krtirtho.spotube.source_matches.$version";
|
||||
|
||||
static LazyBox<SourceMatch> get box => Hive.lazyBox<SourceMatch>(boxName);
|
||||
}
|
119
lib/models/source_match.g.dart
Normal file
119
lib/models/source_match.g.dart
Normal file
@ -0,0 +1,119 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'source_match.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class SourceMatchAdapter extends TypeAdapter<SourceMatch> {
|
||||
@override
|
||||
final int typeId = 6;
|
||||
|
||||
@override
|
||||
SourceMatch read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
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<SourceType> {
|
||||
@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<String, dynamic> 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<String, dynamic> _$SourceMatchToJson(SourceMatch instance) =>
|
||||
<String, dynamic>{
|
||||
'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',
|
||||
};
|
@ -1,290 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/extensions/album_simple.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/models/matched_track.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/youtube/youtube.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
final officialMusicRegex = RegExp(
|
||||
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
class TrackNotFoundException implements Exception {
|
||||
factory TrackNotFoundException(Track track) {
|
||||
throw Exception("Failed to find any results for ${track.name}");
|
||||
}
|
||||
}
|
||||
|
||||
class SpotubeTrack extends Track {
|
||||
final YoutubeVideoInfo ytTrack;
|
||||
final String ytUri;
|
||||
final MusicCodec codec;
|
||||
|
||||
final List<YoutubeVideoInfo> 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<List<YoutubeVideoInfo>> 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<YoutubeVideoInfo> 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<SpotubeTrack> fetchFromTrack(
|
||||
Track track,
|
||||
YoutubeEndpoints client,
|
||||
MusicCodec codec,
|
||||
) async {
|
||||
final matchedCachedTrack = await MatchedTrack.box.get(track.id!);
|
||||
var siblings = <YoutubeVideoInfo>[];
|
||||
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<SpotubeTrack?> 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<String, dynamic> map) {
|
||||
return SpotubeTrack.fromTrack(
|
||||
track: Track.fromJson(map),
|
||||
ytTrack: YoutubeVideoInfo.fromJson(map["ytTrack"]),
|
||||
ytUri: map["ytUri"],
|
||||
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
|
||||
.map((sibling) => YoutubeVideoInfo.fromJson(sibling))
|
||||
.toList(),
|
||||
codec: MusicCodec.values.firstWhere(
|
||||
(element) => element.name == map["codec"],
|
||||
orElse: () => MusicCodec.m4a,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SpotubeTrack> 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<String, dynamic> toJson() {
|
||||
return {
|
||||
// super values
|
||||
"album": album?.toJson(),
|
||||
"artists": artists?.map((artist) => artist.toJson()).toList(),
|
||||
"availableMarkets": availableMarkets?.map((m) => m.name),
|
||||
"discNumber": discNumber,
|
||||
"duration": duration.toString(),
|
||||
"durationMs": durationMs,
|
||||
"explicit": explicit,
|
||||
"href": href,
|
||||
"id": id,
|
||||
"isPlayable": isPlayable,
|
||||
"name": name,
|
||||
"popularity": popularity,
|
||||
"previewUrl": previewUrl,
|
||||
"trackNumber": trackNumber,
|
||||
"type": type,
|
||||
"uri": uri,
|
||||
// this values
|
||||
"ytTrack": ytTrack.toJson(),
|
||||
"ytUri": ytUri,
|
||||
"siblings": siblings.map((sibling) => sibling.toJson()).toList(),
|
||||
"codec": codec.name,
|
||||
};
|
||||
}
|
||||
}
|
@ -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<void> playPlaylist(
|
||||
List<Track> 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,32 +1,19 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/artist/artist_album_list.dart';
|
||||
import 'package:spotube/components/artist/artist_card.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/pages/artist/section/footer.dart';
|
||||
import 'package:spotube/pages/artist/section/header.dart';
|
||||
import 'package:spotube/pages/artist/section/related_artists.dart';
|
||||
import 'package:spotube/pages/artist/section/top_tracks.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class ArtistPage extends HookConsumerWidget {
|
||||
final String artistId;
|
||||
final logger = getLogger(ArtistPage);
|
||||
@ -34,434 +21,61 @@ class ArtistPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
SpotifyApi spotify = ref.watch(spotifyProvider);
|
||||
final parentScrollController = useScrollController();
|
||||
final scrollController = useScrollController();
|
||||
final theme = Theme.of(context);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
final chipTextVariant = useBreakpointValue(
|
||||
xs: textTheme.bodySmall,
|
||||
sm: textTheme.bodySmall,
|
||||
md: textTheme.bodyMedium,
|
||||
lg: textTheme.bodyLarge,
|
||||
xl: textTheme.titleSmall,
|
||||
xxl: textTheme.titleMedium,
|
||||
);
|
||||
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
final avatarWidth = useBreakpointValue(
|
||||
xs: mediaQuery.size.width * 0.50,
|
||||
sm: mediaQuery.size.width * 0.50,
|
||||
md: mediaQuery.size.width * 0.40,
|
||||
lg: mediaQuery.size.width * 0.18,
|
||||
xl: mediaQuery.size.width * 0.18,
|
||||
xxl: mediaQuery.size.width * 0.18,
|
||||
);
|
||||
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
|
||||
final queryClient = useQueryClient();
|
||||
final artistQuery = useQueries.artist.get(ref, artistId);
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Scaffold(
|
||||
appBar: const PageWindowTitleBar(
|
||||
leading: BackButton(),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: HookBuilder(
|
||||
builder: (context) {
|
||||
final artistsQuery = useQueries.artist.get(ref, artistId);
|
||||
|
||||
if (artistsQuery.isLoading || !artistsQuery.hasData) {
|
||||
return const ShimmerArtistProfile();
|
||||
} else if (artistsQuery.hasError) {
|
||||
return Center(
|
||||
child: Text(artistsQuery.error.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
final data = artistsQuery.data!;
|
||||
|
||||
final blacklist = ref.watch(BlackListNotifier.provider);
|
||||
final isBlackListed = blacklist.contains(
|
||||
BlacklistedElement.artist(artistId, data.name!),
|
||||
);
|
||||
|
||||
return InterScrollbar(
|
||||
controller: parentScrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: parentScrollController,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Builder(builder: (context) {
|
||||
if (artistQuery.isLoading || !artistQuery.hasData) {
|
||||
const ShimmerArtistProfile();
|
||||
} else if (artistQuery.hasError) {
|
||||
return Center(child: Text(artistQuery.error.toString()));
|
||||
}
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: [
|
||||
const SizedBox(width: 50),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: CircleAvatar(
|
||||
radius: avatarWidth,
|
||||
backgroundImage: UniversalImage.imageProvider(
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
data.images,
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
borderRadius:
|
||||
BorderRadius.circular(50)),
|
||||
child: Text(
|
||||
data.type!.toUpperCase(),
|
||||
style: chipTextVariant.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isBlackListed) ...[
|
||||
const SizedBox(width: 5),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[400],
|
||||
borderRadius:
|
||||
BorderRadius.circular(50)),
|
||||
child: Text(
|
||||
context.l10n.blacklisted,
|
||||
style: chipTextVariant.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
Text(
|
||||
data.name!,
|
||||
style: mediaQuery.smAndDown
|
||||
? textTheme.headlineSmall
|
||||
: textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
context.l10n.followers(
|
||||
PrimitiveUtils.toReadableNumber(
|
||||
data.followers!.total!.toDouble(),
|
||||
),
|
||||
),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: mediaQuery.mdAndUp
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (auth != null)
|
||||
HookBuilder(
|
||||
builder: (context) {
|
||||
final isFollowingQuery = useQueries
|
||||
.artist
|
||||
.doIFollow(ref, artistId);
|
||||
|
||||
final followUnfollow =
|
||||
useCallback(() async {
|
||||
try {
|
||||
isFollowingQuery.data!
|
||||
? await spotify.me.unfollow(
|
||||
FollowingType.artist,
|
||||
[artistId],
|
||||
)
|
||||
: await spotify.me.follow(
|
||||
FollowingType.artist,
|
||||
[artistId],
|
||||
);
|
||||
await isFollowingQuery.refresh();
|
||||
|
||||
queryClient
|
||||
.refreshInfiniteQueryAllPages(
|
||||
"user-following-artists");
|
||||
} finally {
|
||||
queryClient.refreshQuery(
|
||||
"user-follows-artists-query/$artistId",
|
||||
);
|
||||
}
|
||||
}, [isFollowingQuery]);
|
||||
|
||||
if (isFollowingQuery.isLoading ||
|
||||
!isFollowingQuery.hasData) {
|
||||
return const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child:
|
||||
CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (isFollowingQuery.data!) {
|
||||
return OutlinedButton(
|
||||
onPressed: followUnfollow,
|
||||
child:
|
||||
Text(context.l10n.following),
|
||||
);
|
||||
}
|
||||
|
||||
return FilledButton(
|
||||
onPressed: followUnfollow,
|
||||
child: Text(context.l10n.follow),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
IconButton(
|
||||
tooltip:
|
||||
context.l10n.add_artist_to_blacklist,
|
||||
icon: Icon(
|
||||
SpotubeIcons.userRemove,
|
||||
color: !isBlackListed
|
||||
? Colors.red[400]
|
||||
: Colors.white,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: isBlackListed
|
||||
? Colors.red[400]
|
||||
: null,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (isBlackListed) {
|
||||
ref
|
||||
.read(BlackListNotifier
|
||||
.provider.notifier)
|
||||
.remove(
|
||||
BlacklistedElement.artist(
|
||||
data.id!, data.name!),
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(BlackListNotifier
|
||||
.provider.notifier)
|
||||
.add(
|
||||
BlacklistedElement.artist(
|
||||
data.id!, data.name!),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.share),
|
||||
onPressed: () async {
|
||||
if (data.externalUrls?.spotify !=
|
||||
null) {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: data.externalUrls!.spotify!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
context.l10n.artist_url_copied,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
HookBuilder(
|
||||
builder: (context) {
|
||||
final topTracksQuery = useQueries.artist.topTracksOf(
|
||||
ref,
|
||||
artistId,
|
||||
);
|
||||
|
||||
final isPlaylistPlaying = playlist.containsTracks(
|
||||
topTracksQuery.data ?? <Track>[],
|
||||
);
|
||||
|
||||
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<Track> tracks,
|
||||
{Track? currentTrack}) async {
|
||||
currentTrack ??= tracks.first;
|
||||
if (!isPlaylistPlaying) {
|
||||
playlistNotifier.load(
|
||||
tracks,
|
||||
initialIndex: tracks.indexWhere(
|
||||
(s) => s.id == currentTrack?.id),
|
||||
autoPlay: true,
|
||||
);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != playlist.activeTrack?.id) {
|
||||
await playlistNotifier.jumpToTrack(currentTrack);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
context.l10n.top_tracks,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
if (!isPlaylistPlaying)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
SpotubeIcons.queueAdd,
|
||||
),
|
||||
onPressed: () {
|
||||
playlistNotifier
|
||||
.addTracks(topTracks.toList());
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
context.l10n.added_to_queue(
|
||||
topTracks.length,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isPlaylistPlaying
|
||||
? SpotubeIcons.stop
|
||||
: SpotubeIcons.play,
|
||||
color: Colors.white,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor:
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
onPressed: () =>
|
||||
playPlaylist(topTracks.toList()),
|
||||
)
|
||||
],
|
||||
),
|
||||
...topTracks.mapIndexed((i, track) {
|
||||
return TrackTile(
|
||||
index: i,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
playPlaylist(
|
||||
topTracks.toList(),
|
||||
currentTrack: track,
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
context.l10n.albums,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
ArtistAlbumList(artistId),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
context.l10n.fans_also_like,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
HookBuilder(
|
||||
builder: (context) {
|
||||
final relatedArtists =
|
||||
useQueries.artist.relatedArtistsOf(
|
||||
ref,
|
||||
artistId,
|
||||
);
|
||||
|
||||
if (relatedArtists.isLoading ||
|
||||
!relatedArtists.hasData) {
|
||||
return const CircularProgressIndicator();
|
||||
} else if (relatedArtists.hasError) {
|
||||
return Center(
|
||||
child: Text(relatedArtists.error.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Wrap(
|
||||
spacing: 20,
|
||||
runSpacing: 20,
|
||||
children: relatedArtists.data!
|
||||
.map((artist) => ArtistCard(artist))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
bottom: false,
|
||||
child: ArtistPageHeader(artistId: artistId),
|
||||
),
|
||||
),
|
||||
const SliverGap(50),
|
||||
ArtistPageTopTracks(artistId: artistId),
|
||||
const SliverGap(50),
|
||||
SliverToBoxAdapter(child: ArtistAlbumList(artistId)),
|
||||
const SliverGap(20),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
context.l10n.fans_also_like,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverSafeArea(
|
||||
sliver: ArtistPageRelatedArtists(artistId: artistId),
|
||||
),
|
||||
if (artistQuery.data != null)
|
||||
SliverSafeArea(
|
||||
top: false,
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: ArtistPageFooter(artist: artistQuery.data!),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
93
lib/pages/artist/section/footer.dart
Normal file
93
lib/pages/artist/section/footer.dart
Normal file
@ -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}",
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
257
lib/pages/artist/section/header.dart
Normal file
257
lib/pages/artist/section/header.dart
Normal file
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
49
lib/pages/artist/section/related_artists.dart
Normal file
49
lib/pages/artist/section/related_artists.dart
Normal file
@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user