Merge pull request #1405 from KRTirtho/dev

Release version 3.6.0
This commit is contained in:
Kingkor Roy Tirtho 2024-04-15 19:41:40 +06:00 committed by GitHub
commit cb95663412
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
326 changed files with 12233 additions and 6495 deletions

View File

@ -4,13 +4,15 @@ on:
pull_request: pull_request:
env: env:
FLUTTER_VERSION: '3.16.0' FLUTTER_VERSION: '3.19.5'
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}

View File

@ -66,7 +66,7 @@ jobs:
- name: Release to AUR - name: Release to AUR
if: ${{ !inputs.dry_run }} if: ${{ !inputs.dry_run }}
uses: KSXGitHub/github-actions-deploy-aur@v2.7.0 uses: KSXGitHub/github-actions-deploy-aur@v2.7.1
with: with:
pkgname: spotube-bin pkgname: spotube-bin
pkgbuild: aur-struct/PKGBUILD pkgbuild: aur-struct/PKGBUILD

View File

@ -4,7 +4,7 @@ on:
inputs: inputs:
version: version:
description: Version to release (x.x.x) description: Version to release (x.x.x)
default: 3.4.1 default: 3.6.0
required: true required: true
channel: channel:
type: choice type: choice
@ -284,7 +284,7 @@ jobs:
macos: macos:
runs-on: macos-12 runs-on: macos-14
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0 - uses: subosito/flutter-action@v2.12.0
@ -327,7 +327,7 @@ jobs:
- name: Package Macos App - name: Package Macos App
run: | run: |
python3 -m pip install setuptools brew install python-setuptools
npm install -g appdmg npm install -g appdmg
mkdir -p build/${{ env.BUILD_VERSION }} mkdir -p build/${{ env.BUILD_VERSION }}
appdmg appdmg.json build/Spotube-macos-universal.dmg appdmg appdmg.json build/Spotube-macos-universal.dmg
@ -349,7 +349,7 @@ jobs:
limit-access-to-actor: true limit-access-to-actor: true
iOS: iOS:
runs-on: macos-latest runs-on: macos-14
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.10.0 - uses: subosito/flutter-action@v2.10.0

View File

@ -2,11 +2,19 @@
"cmake.configureOnOpen": false, "cmake.configureOnOpen": false,
"cSpell.words": [ "cSpell.words": [
"acousticness", "acousticness",
"ambiguate",
"Amoled",
"Buildless",
"danceability", "danceability",
"fuzzywuzzy",
"gapless",
"instrumentalness", "instrumentalness",
"Mpris", "Mpris",
"RGBO",
"riverpod", "riverpod",
"Scrobblenaut", "Scrobblenaut",
"skeletonizer",
"songlink",
"speechiness", "speechiness",
"Spotube", "Spotube",
"winget" "winget"

170
.vscode/snippets.code-snippets vendored Normal file
View File

@ -0,0 +1,170 @@
{
"PaginatedState": {
"scope": "dart",
"prefix": "paginatedState",
"description": "Generate a PaginatedState",
"body": [
"class ${1:Model}State extends PaginatedState<${2:Model}> {",
" ${1:Model}State({",
" required super.items,",
" required super.offset,",
" required super.limit,",
" required super.hasMore,",
" });",
" ",
" @override",
" ${1:Model}State copyWith({",
" List<${2:Model}>? items,",
" int? offset,",
" int? limit,",
" bool? hasMore,",
" }) {",
" return ${1:Model}State(",
" items: items ?? this.items,",
" offset: offset ?? this.offset,",
" limit: limit ?? this.limit,",
" hasMore: hasMore ?? this.hasMore,",
" );",
" }",
"}"
]
},
"PaginatedAsyncNotifier": {
"scope": "dart",
"prefix": "paginatedAsyncNotifier",
"description": "Generate a PaginatedAsyncNotifier",
"body": [
"class ${1:NotifierName}Notifier extends PaginatedAsyncNotifier<${3:Item}, ${2:Model}State> {",
" ${1:NotifierName}Notifier() : super();",
" ",
" @override",
" fetch(int offset, int limit) async {",
" throw UnimplementedError();",
" }",
" ",
" @override",
" build() async {",
" throw UnimplementedError();",
" }",
"}"
]
},
"PaginaitedNotifierWithState": {
"scope": "dart",
"prefix": "paginatedNotifierWithState",
"description": "Generate a PaginatedNotifier with PaginatedState",
"body": [
"class $1State extends PaginatedState<$2> {",
" $1State({",
" required super.items,",
" required super.offset,",
" required super.limit,",
" required super.hasMore,",
" });",
" ",
" @override",
" $1State copyWith({",
" List<$2>? items,",
" int? offset,",
" int? limit,",
" bool? hasMore,",
" }) {",
" return $1State(",
" items: items ?? this.items,",
" offset: offset ?? this.offset,",
" limit: limit ?? this.limit,",
" hasMore: hasMore ?? this.hasMore,",
" );",
" }",
"}",
" ",
"class $1Notifier",
" extends PaginatedAsyncNotifier<$2, $1State> {",
" $1Notifier() : super();",
" ",
" @override",
" fetch(int offset, int limit) async {",
" throw UnimplementedError();",
" }",
" ",
" @override",
" build() async {",
" throw UnimplementedError();",
" }",
"}",
" ",
"final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProvider<$1Notifier, $1State>(",
" ()=> $1Notifier(),",
");"
]
},
"FamilyPaginatedAsyncNotifier": {
"scope": "dart",
"prefix": "familyPaginatedAsyncNotifier",
"description": "Generate a FamilyPaginatedAsyncNotifier",
"body": [
"class ${1:NotifierName}Notifier extends FamilyPaginatedAsyncNotifier<${3:Item}, ${2:Model}State, {$4:Arg}> {",
" ${1:NotifierName}Notifier() : super();",
" ",
" @override",
" fetch(arg, offset, limit) async {",
" throw UnimplementedError();",
" }",
" ",
" @override",
" build(arg) async {",
" throw UnimplementedError();",
" }",
"}"
]
},
"FamilyPaginaitedNotifierWithState": {
"scope": "dart",
"prefix": "familyPaginatedNotifierWithState",
"description": "Generate a FamilyPaginatedAsyncNotifier with PaginatedState",
"body": [
"class $1State extends PaginatedState<$2> {",
" $1State({",
" required super.items,",
" required super.offset,",
" required super.limit,",
" required super.hasMore,",
" });",
" ",
" @override",
" $1State copyWith({",
" List<$2>? items,",
" int? offset,",
" int? limit,",
" bool? hasMore,",
" }) {",
" return $1State(",
" items: items ?? this.items,",
" offset: offset ?? this.offset,",
" limit: limit ?? this.limit,",
" hasMore: hasMore ?? this.hasMore,",
" );",
" }",
"}",
" ",
"class $1Notifier",
" extends FamilyPaginatedAsyncNotifier<$2, $1State, $3> {",
" $1Notifier() : super();",
" ",
" @override",
" fetch(arg, offset, limit) async {",
" throw UnimplementedError();",
" }",
" ",
" @override",
" build(arg) async {",
" throw UnimplementedError();",
" }",
"}",
" ",
"final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProviderFamily<$1Notifier, $1State, $3>(",
" ()=> $1Notifier(),",
");"
]
},
}

View File

@ -2,6 +2,27 @@
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. 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.6.0-0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0-0) (2024-04-15)
### Features
* add Spotify homepage personalized recommendations ([#1402](https://github.com/krtirtho/spotube/issues/1402)) ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11))
* add user profile page ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e))
* **android:** Filter Device To Force High Frame Rate ([#880](https://github.com/krtirtho/spotube/issues/880)) ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea))
* improved caching based on riverpod ([#1343](https://github.com/krtirtho/spotube/issues/1343)) ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771))
* LAN connect a.k.a control remote Spotube playback and local output device selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78))
* **lyrics:** add LRCLIB lyrics provider as fallback ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632))
* search history support [#1236](https://github.com/krtirtho/spotube/issues/1236) ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468))
* **translations:** Add Czech translation ([#1401](https://github.com/krtirtho/spotube/issues/1401)) ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4))
* **translations:** add Thai Language ([#1319](https://github.com/krtirtho/spotube/issues/1319)) ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311)
### Bug Fixes
* instance of Artist bug [#1362](https://github.com/krtirtho/spotube/issues/1362) ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde))
* **playback:** sponsor block skips and stutters in same position ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73))
## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) ## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08)

View File

@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
- [Before Submitting an Enhancement](#before-submitting-an-enhancement) - [Before Submitting an Enhancement](#before-submitting-an-enhancement)
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion) - [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
- [Your First Code Contribution](#your-first-code-contribution) - [Your First Code Contribution](#your-first-code-contribution)
- [Submit translations](#submit-translations) - [Submit Translations](#submit-translations)
## Code of Conduct ## Code of Conduct
@ -123,16 +123,16 @@ Do the following:
- Install Development dependencies in linux - Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu - Debian (>=12/Bookworm)/Ubuntu
```bash ```bash
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan
``` ```
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
- Arch/Manjaro - Arch/Manjaro
```bash ```bash
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
``` ```
- Fedora - Fedora
```bash ```bash
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns
``` ```
- Clone the Repo - Clone the Repo
- Create a `.env` in root of the project following the `.env.example` template - Create a `.env` in root of the project following the `.env.example` template

View File

@ -200,6 +200,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
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. [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. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages
1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content 1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content
1. [LRCLib](https://lrclib.net/) - A public synced lyric API
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. [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. [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. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux
@ -228,9 +229,6 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. 1. [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. [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. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI.
1. [fl_query](https://fl-query.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. [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_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_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.
@ -252,7 +250,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. 1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests.
1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. 1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera.
1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues. 1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues.
1. [introduction_screen](https://github.com/pyozer/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities 1. [introduction_screen](https://pub.dev/packages/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities
1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. 1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package.
1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. 1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs.
1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. 1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular.
@ -290,22 +288,32 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter 1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter
1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort. 1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort.
1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included). 1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included).
1. [win32_registry](https://win32.pub) - A package that provides a friendly Dart API for accessing the Windows Registry. 1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry.
1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. 1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app.
1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. 1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications.
1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. 1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too.
1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. 1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API.
1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD.
1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse.
1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations.
1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection.
1. [web_socket_channel](https://pub.dev/packages/web_socket_channel) - StreamChannel wrappers for WebSockets. Provides a cross-platform WebSocketChannel API, a cross-platform implementation of that API that communicates over an underlying StreamChannel.
1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics.
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. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime.
1. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions.
1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 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. [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. 1. [flutter_distributor](https://distributor.leanflutter.dev) - A complete tool for packaging and publishing your Flutter apps.
1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs. 1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs.
1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon. 1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon.
1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices. 1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices.
1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. 1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class.
1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. 1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes.
1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information.
1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting.
1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. 1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features.
1. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules.
1. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks.
1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development 1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development
1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video
1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. 1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark.

View File

@ -25,12 +25,17 @@ linter:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
file_names: false file_names: false
avoid_renaming_method_parameters: false
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
analyzer: analyzer:
enable-experiment:
- records
- patterns
errors: errors:
invalid_annotation_target: ignore invalid_annotation_target: ignore
plugins:
- custom_lint
exclude:
- "**.freezed.dart"
- "**.g.dart"
- "**.gr.dart"
- "**/generated_plugin_registrant.dart"

View File

@ -1,3 +1,5 @@
// ignore_for_file: avoid_print
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';

View File

@ -1,3 +1,5 @@
// ignore_for_file: avoid_print
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@ -40,7 +42,6 @@ void main(List<String> args) {
"Translate following to their appropriate locale for flutter arb translations files." "Translate following to their appropriate locale for flutter arb translations files."
" Put the respective new translations in a map of their corresponding locale.", " Put the respective new translations in a map of their corresponding locale.",
); );
// ignore: avoid_print
print( print(
const JsonEncoder.withIndent(' ').convert( const JsonEncoder.withIndent(' ').convert(
args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues, args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues,

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Uncomment this line to define a global platform for your project
# platform :ios, '12.0' platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@ -5,6 +5,9 @@ PODS:
- Flutter - Flutter
- audio_session (0.0.1): - audio_session (0.0.1):
- Flutter - Flutter
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
- Flutter - Flutter
- DKImagePickerController/Core (4.3.4): - DKImagePickerController/Core (4.3.4):
@ -44,11 +47,13 @@ PODS:
- file_selector_ios (0.0.1): - file_selector_ios (0.0.1):
- Flutter - Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_inappwebview (0.0.1): - flutter_broadcasts (0.0.1):
- Flutter - Flutter
- flutter_inappwebview/Core (= 0.0.1) - flutter_inappwebview_ios (0.0.1):
- Flutter
- flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0) - OrderedSet (~> 5.0)
- flutter_inappwebview/Core (0.0.1): - flutter_inappwebview_ios/Core (0.0.1):
- Flutter - Flutter
- OrderedSet (~> 5.0) - OrderedSet (~> 5.0)
- flutter_keyboard_visibility (0.0.1): - flutter_keyboard_visibility (0.0.1):
@ -102,11 +107,13 @@ DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`) - app_links (from `.symlinks/plugins/app_links/ios`)
- audio_service (from `.symlinks/plugins/audio_service/ios`) - audio_service (from `.symlinks/plugins/audio_service/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`) - audio_session (from `.symlinks/plugins/audio_session/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`) - flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
@ -142,6 +149,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/audio_service/ios" :path: ".symlinks/plugins/audio_service/ios"
audio_session: audio_session:
:path: ".symlinks/plugins/audio_session/ios" :path: ".symlinks/plugins/audio_session/ios"
bonsoir_darwin:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
device_info_plus: device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios" :path: ".symlinks/plugins/device_info_plus/ios"
file_picker: file_picker:
@ -150,8 +159,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/file_selector_ios/ios" :path: ".symlinks/plugins/file_selector_ios/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_inappwebview: flutter_broadcasts:
:path: ".symlinks/plugins/flutter_inappwebview/ios" :path: ".symlinks/plugins/flutter_broadcasts/ios"
flutter_inappwebview_ios:
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_keyboard_visibility: flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios" :path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_mailer: flutter_mailer:
@ -191,13 +202,15 @@ SPEC CHECKSUMS:
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_service: f509d65da41b9521a61f1c404dd58651f265a567
audio_session: 4f3e461722055d21515cf3261b64c973c062f345 audio_session: 4f3e461722055d21515cf3261b64c973c062f345
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62 flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
@ -221,6 +234,6 @@ SPEC CHECKSUMS:
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
COCOAPODS: 1.15.2 COCOAPODS: 1.15.2

View File

@ -66,5 +66,11 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true /> <true />
<key>NSLocalNetworkUsageDescription</key>
<string>To allow other devices on the network control playback of Spotube securely.</string>
<key>NSBonjourServices</key>
<array>
<string>_spotube._tcp</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -88,7 +88,7 @@ class Assets {
AssetGenImage('assets/user-placeholder.png'); AssetGenImage('assets/user-placeholder.png');
/// List of all assets /// List of all assets
List<dynamic> get values => [ static List<dynamic> get values => [
albumPlaceholder, albumPlaceholder,
bengaliPatternsBg, bengaliPatternsBg,
branding, branding,

View File

@ -1,12 +1,13 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/spotify/home_feed.dart';
import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/models/spotify_friends.dart';
abstract class FakeData { abstract class FakeData {
static final Image image = Image() static final Image image = Image()
..height = 1 ..height = 1
..width = 1 ..width = 1
..url = "url"; ..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg";
static final Followers followers = Followers() static final Followers followers = Followers()
..href = "text" ..href = "text"
@ -196,4 +197,30 @@ abstract class FakeData {
), ),
], ],
); );
static final feedSection = SpotifyHomeFeedSection(
typename: "HomeGenericSectionData",
uri: "spotify:section:lol",
title: "Dummy",
items: [
for (int i = 0; i < 10; i++)
SpotifyHomeFeedSectionItem(
typename: "PlaylistResponseWrapper",
playlist: SpotifySectionPlaylist(
name: "Playlist $i",
description: "Really super important description $i",
format: "daily-mix",
images: [
const SpotifySectionItemImage(
height: 1,
width: 1,
url: "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg",
),
],
owner: "Spotify",
uri: "spotify:playlist:id",
),
)
],
);
} }

View File

@ -92,7 +92,7 @@ class SeekIntent extends Intent {
class SeekAction extends Action<SeekIntent> { class SeekAction extends Action<SeekIntent> {
@override @override
invoke(intent) async { invoke(intent) async {
final playlist = intent.ref.read(ProxyPlaylistNotifier.provider); final playlist = intent.ref.read(proxyPlaylistProvider);
if (playlist.isFetching) { if (playlist.isFetching) {
DirectionalFocusAction().invoke( DirectionalFocusAction().invoke(
DirectionalFocusIntent( DirectionalFocusIntent(

View File

@ -157,10 +157,10 @@ abstract class LanguageLocals {
// name: "Croatian", // name: "Croatian",
// nativeName: "hrvatski", // nativeName: "hrvatski",
// ), // ),
// "cs": const ISOLanguageName( "cs": const ISOLanguageName(
// name: "Czech", name: "Czech",
// nativeName: "česky, čeština", nativeName: "česky, čeština",
// ), ),
// "da": const ISOLanguageName( // "da": const ISOLanguageName(
// name: "Danish", // name: "Danish",
// nativeName: "dansk", // nativeName: "dansk",
@ -637,10 +637,10 @@ abstract class LanguageLocals {
// name: "Tajik", // name: "Tajik",
// nativeName: "тоҷикӣ, toğikī, تاجیکی‎", // nativeName: "тоҷикӣ, toğikī, تاجیکی‎",
// ), // ),
// "th": const ISOLanguageName( "th": const ISOLanguageName(
// name: "Thai", name: "Thai",
// nativeName: "ไทย", nativeName: "ไทย",
// ), ),
// "ti": const ISOLanguageName( // "ti": const ISOLanguageName(
// name: "Tigrinya", // name: "Tigrinya",
// nativeName: "ትግርኛ", // nativeName: "ትግርኛ",

View File

@ -4,8 +4,12 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/connect/connect.dart';
import 'package:spotube/pages/connect/control/control.dart';
import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/getting_started/getting_started.dart';
import 'package:spotube/pages/home/feed/feed_section.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
@ -15,6 +19,7 @@ import 'package:spotube/pages/library/playlist_generate/playlist_generate_result
import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/playlist/liked_playlist.dart'; import 'package:spotube/pages/playlist/liked_playlist.dart';
import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/pages/profile/profile.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/about.dart';
@ -46,8 +51,7 @@ final routerProvider = Provider((ref) {
GoRoute( GoRoute(
path: "/", path: "/",
redirect: (context, state) async { redirect: (context, state) async {
final authNotifier = final authNotifier = ref.read(authenticationProvider.notifier);
ref.read(AuthenticationNotifier.provider.notifier);
final json = await authNotifier.box.get(authNotifier.cacheKey); final json = await authNotifier.box.get(authNotifier.cacheKey);
if (json?["cookie"] == null && if (json?["cookie"] == null &&
@ -73,6 +77,14 @@ final routerProvider = Provider((ref) {
), ),
), ),
), ),
GoRoute(
path: "feeds/:feedId",
pageBuilder: (context, state) => SpotubePage(
child: HomeFeedSectionPage(
sectionUri: state.pathParameters["feedId"] as String,
),
),
)
], ],
), ),
GoRoute( GoRoute(
@ -96,8 +108,7 @@ final routerProvider = Provider((ref) {
path: "result", path: "result",
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: PlaylistGenerateResultPage( child: PlaylistGenerateResultPage(
state: state: state.extra as GeneratePlaylistProviderInput,
state.extra as PlaylistGenerateResultRouteState,
), ),
), ),
), ),
@ -173,6 +184,27 @@ final routerProvider = Provider((ref) {
); );
}, },
), ),
GoRoute(
path: "/connect",
pageBuilder: (context, state) => const SpotubePage(
child: ConnectPage(),
),
routes: [
GoRoute(
path: "control",
pageBuilder: (context, state) {
return const SpotubePage(
child: ConnectControlPage(),
);
},
)
],
),
GoRoute(
path: "/profile",
pageBuilder: (context, state) =>
const SpotubePage(child: ProfilePage()),
)
], ],
), ),
GoRoute( GoRoute(

View File

@ -115,4 +115,10 @@ abstract class SpotubeIcons {
static const github = SimpleIcons.github; static const github = SimpleIcons.github;
static const openCollective = SimpleIcons.opencollective; static const openCollective = SimpleIcons.opencollective;
static const anonymous = FeatherIcons.user; static const anonymous = FeatherIcons.user;
static const history = FeatherIcons.clock;
static const connect = FeatherIcons.link;
static const speaker = FeatherIcons.speaker;
static const monitor = FeatherIcons.monitor;
static const power = FeatherIcons.power;
static const bluetooth = FeatherIcons.bluetooth;
} }

View File

@ -1,17 +1,19 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/queries/album.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
extension FormattedAlbumType on AlbumType { extension FormattedAlbumType on AlbumType {
String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); String get formatted => name.replaceFirst(name[0], name[0].toUpperCase());
@ -26,12 +28,10 @@ class AlbumCard extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final queryClient = useQueryClient();
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
() => playlist.containsCollection(album.id!), () => playlist.containsCollection(album.id!),
@ -39,39 +39,19 @@ class AlbumCard extends HookConsumerWidget {
); );
final updating = useState(false); final updating = useState(false);
final spotify = ref.watch(spotifyProvider);
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
Future<List<Track>> fetchAllTrack() async { Future<List<Track>> fetchAllTrack() async {
if (album.tracks != null && album.tracks!.isNotEmpty) { if (album.tracks != null && album.tracks!.isNotEmpty) {
return album.tracks! return album.tracks!.map((track) => track.asTrack(album)).toList();
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList();
} }
final job = AlbumQueries.tracksOfJob(album.id!); await ref.read(albumTracksProvider(album).future);
return ref.read(albumTracksProvider(album).notifier).fetchAll();
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( return PlaybuttonCard(
imageUrl: TypeConversionUtils.image_X_UrlString( imageUrl: album.images.asUrlString(
album.images,
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),
margin: const EdgeInsets.symmetric(horizontal: 10), margin: const EdgeInsets.symmetric(horizontal: 10),
@ -80,7 +60,7 @@ class AlbumCard extends HookConsumerWidget {
updating.value, updating.value,
title: album.name!, title: album.name!,
description: description:
"${album.albumType?.formatted}${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}", "${album.albumType?.formatted}${album.artists?.asString() ?? ""}",
onTap: () { onTap: () {
ServiceUtils.push(context, "/album/${album.id}", extra: album); ServiceUtils.push(context, "/album/${album.id}", extra: album);
}, },
@ -93,10 +73,21 @@ class AlbumCard extends HookConsumerWidget {
final fetchedTracks = await fetchAllTrack(); final fetchedTracks = await fetchAllTrack();
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty || !context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData(
tracks: fetchedTracks,
collectionId: album.id!,
),
);
} else {
await playlistNotifier.load(fetchedTracks, autoPlay: true); await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
}
} finally { } finally {
updating.value = false; updating.value = false;
} }

View File

@ -1,38 +1,35 @@
import 'package:flutter/material.dart' hide Page; import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class ArtistAlbumList extends HookConsumerWidget { class ArtistAlbumList extends HookConsumerWidget {
final String artistId; final String artistId;
ArtistAlbumList( ArtistAlbumList(
this.artistId, { this.artistId, {
Key? key, super.key,
}) : super(key: key); });
final logger = getLogger(ArtistAlbumList); final logger = getLogger(ArtistAlbumList);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final albumsQuery = useQueries.artist.albumsOf(ref, artistId); final albumsQuery = ref.watch(artistAlbumsProvider(artistId));
final albumsQueryNotifier =
ref.watch(artistAlbumsProvider(artistId).notifier);
final albums = useMemoized(() { final albums = albumsQuery.asData?.value.items ?? [];
return albumsQuery.pages
.expand<Album>((page) => page.items ?? const Iterable.empty())
.toList();
}, [albumsQuery.pages]);
final theme = Theme.of(context); final theme = Theme.of(context);
return HorizontalPlaybuttonCardView<Album>( return HorizontalPlaybuttonCardView<Album>(
isLoadingNextPage: albumsQuery.isLoadingNextPage, isLoadingNextPage: albumsQuery.isLoadingNextPage,
hasNextPage: albumsQuery.hasNextPage, hasNextPage: albumsQuery.asData?.value.hasMore ?? false,
items: albums, items: albums,
onFetchMore: albumsQuery.fetchNext, onFetchMore: albumsQueryNotifier.fetchMore,
title: Text( title: Text(
context.l10n.albums, context.l10n.albums,
style: theme.textTheme.headlineSmall, style: theme.textTheme.headlineSmall,

View File

@ -6,27 +6,26 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class ArtistCard extends HookConsumerWidget { class ArtistCard extends HookConsumerWidget {
final Artist artist; final Artist artist;
const ArtistCard(this.artist, {Key? key}) : super(key: key); const ArtistCard(this.artist, {super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final backgroundImage = UniversalImage.imageProvider( final backgroundImage = UniversalImage.imageProvider(
TypeConversionUtils.image_X_UrlString( artist.images.asUrlString(
artist.images,
placeholder: ImagePlaceholder.artist, placeholder: ImagePlaceholder.artist,
), ),
); );
final isBlackListed = ref.watch( final isBlackListed = ref.watch(
BlackListNotifier.provider.select( blacklistProvider.select(
(blacklist) => blacklist.contains( (blacklist) => blacklist.contains(
BlacklistedElement.artist(artist.id!, artist.name!), BlacklistedElement.artist(artist.id!, artist.name!),
), ),

View File

@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/utils/service_utils.dart';
class ConnectDeviceButton extends HookConsumerWidget {
final bool _sidebar;
const ConnectDeviceButton({super.key}) : _sidebar = false;
const ConnectDeviceButton.sidebar({super.key}) : _sidebar = true;
@override
Widget build(BuildContext context, ref) {
final ThemeData(:colorScheme) = Theme.of(context);
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final connectClients = ref.watch(connectClientsProvider);
if (_sidebar) {
return SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () {
ServiceUtils.push(context, "/connect");
},
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(5),
),
child: Row(
children: [
Text(context.l10n.devices),
if (connectClients.asData?.value.services.isNotEmpty == true)
Text(
" (${connectClients.asData?.value.services.length})",
),
const Spacer(),
const Icon(SpotubeIcons.speaker),
const Gap(5),
],
),
),
);
}
return SizedBox(
height: 40 * pixelRatio,
child: Stack(
alignment: Alignment.centerRight,
fit: StackFit.loose,
children: [
Material(
type: MaterialType.transparency,
child: Center(
child: ClipRect(
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () {
ServiceUtils.push(context, "/connect");
},
borderRadius: BorderRadius.circular(50),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50),
color: colorScheme.primaryContainer,
),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (connectClients.asData?.value.resolvedService !=
null) ...[
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: Colors.greenAccent,
borderRadius: BorderRadius.circular(50),
),
),
const Gap(5),
],
Text(context.l10n.devices),
if (connectClients.asData?.value.services.isNotEmpty ==
true)
Text(
" (${connectClients.asData?.value.services.length})",
style: TextStyle(
color: colorScheme.onPrimaryContainer
.withOpacity(0.5),
),
),
const Gap(35),
],
),
),
),
),
),
),
Positioned(
right: -3,
child: IconButton.filled(
icon: const Icon(SpotubeIcons.speaker),
style: IconButton.styleFrom(
visualDensity: VisualDensity.standard,
foregroundColor: colorScheme.onPrimary,
),
onPressed: () {
ServiceUtils.push(context, "/connect");
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
class ConnectPageLocalDevices extends HookWidget {
const ConnectPageLocalDevices({super.key});
@override
Widget build(BuildContext context) {
final ThemeData(:textTheme) = Theme.of(context);
final devicesFuture = useFuture(audioPlayer.devices);
final devicesStream = useStream(audioPlayer.devicesStream);
final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice);
final selectedDeviceStream = useStream(audioPlayer.selectedDeviceStream);
final devices = devicesStream.data ?? devicesFuture.data;
final selectedDevice =
selectedDeviceStream.data ?? selectedDeviceFuture.data;
if (devices == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
return SliverMainAxisGroup(
slivers: [
const SliverGap(10),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.this_device,
style: textTheme.titleMedium,
),
),
),
const SliverGap(10),
SliverList.separated(
itemCount: devices.length,
separatorBuilder: (context, index) => const Gap(10),
itemBuilder: (context, index) {
final device = devices[index];
return Card(
child: ListTile(
leading: const Icon(SpotubeIcons.speaker),
title: Text(device.description),
subtitle: Text(device.name),
selected: selectedDevice == device,
onTap: () => audioPlayer.setAudioDevice(device),
),
);
},
),
],
);
}
}

View File

@ -8,14 +8,13 @@ import 'package:spotube/provider/authentication_provider.dart';
class TokenLoginForm extends HookConsumerWidget { class TokenLoginForm extends HookConsumerWidget {
final void Function()? onDone; final void Function()? onDone;
const TokenLoginForm({ const TokenLoginForm({
Key? key, super.key,
this.onDone, this.onDone,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final authenticationNotifier = final authenticationNotifier = ref.watch(authenticationProvider.notifier);
ref.watch(AuthenticationNotifier.provider.notifier);
final directCodeController = useTextEditingController(); final directCodeController = useTextEditingController();
final mounted = useIsMounted(); final mounted = useIsMounted();

View File

@ -1,35 +1,28 @@
import 'package:flutter/material.dart' hide Page; import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class HomeFeaturedSection extends HookConsumerWidget { class HomeFeaturedSection extends HookConsumerWidget {
const HomeFeaturedSection({Key? key}) : super(key: key); const HomeFeaturedSection({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final featuredPlaylistsQuery = useQueries.playlist.featured(ref); final featuredPlaylists = ref.watch(featuredPlaylistsProvider);
final playlists = useMemoized( final featuredPlaylistsNotifier =
() => featuredPlaylistsQuery.pages ref.watch(featuredPlaylistsProvider.notifier);
.whereType<Page<PlaylistSimple>>()
.expand((page) => page.items ?? const <PlaylistSimple>[]),
[featuredPlaylistsQuery.pages],
);
final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData &&
!featuredPlaylistsQuery.isLoadingNextPage;
return Skeletonizer( return Skeletonizer(
enabled: isLoadingFeaturedPlaylists, enabled: featuredPlaylists.isLoading,
child: HorizontalPlaybuttonCardView<PlaylistSimple>( child: HorizontalPlaybuttonCardView<PlaylistSimple>(
items: playlists.toList(), items: featuredPlaylists.asData?.value.items ?? [],
title: Text(context.l10n.featured), title: Text(context.l10n.featured),
isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage, isLoadingNextPage: featuredPlaylists.isLoadingNextPage,
hasNextPage: featuredPlaylistsQuery.hasNextPage, hasNextPage: featuredPlaylists.asData?.value.hasMore ?? false,
onFetchMore: featuredPlaylistsQuery.fetchNext, onFetchMore: featuredPlaylistsNotifier.fetchMore,
), ),
); );
} }

View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/provider/spotify/views/home.dart';
import 'package:spotube/utils/service_utils.dart';
class HomePageFeedSection extends HookConsumerWidget {
const HomePageFeedSection({super.key});
@override
Widget build(BuildContext context, ref) {
final homeFeed = ref.watch(homeViewProvider);
final nonShortSections = homeFeed.asData?.value?.sections
.where((s) => s.typename == "HomeGenericSectionData")
.toList() ??
[];
return SliverList.builder(
itemCount: nonShortSections.length,
itemBuilder: (context, index) {
final section = nonShortSections[index];
if (section.items.isEmpty) return const SizedBox.shrink();
return HorizontalPlaybuttonCardView(
items: [
for (final item in section.items)
if (item.album != null)
item.album!.asAlbum
else if (item.artist != null)
item.artist!.asArtist
else if (item.playlist != null)
item.playlist!.asPlaylist
],
title: Text(section.title ?? "No Titel"),
hasNextPage: false,
isLoadingNextPage: false,
onFetchMore: () {},
titleTrailing: Directionality(
textDirection: TextDirection.rtl,
child: TextButton.icon(
label: const Text("Browse More"),
icon: const Icon(SpotubeIcons.angleRight),
onPressed: () =>
ServiceUtils.push(context, "/feeds/${section.uri}"),
),
),
);
},
);
}
}

View File

@ -1,4 +1,3 @@
import 'dart:ffi';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -8,15 +7,16 @@ import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/home/sections/friends/friend_item.dart'; import 'package:spotube/components/home/sections/friends/friend_item.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/models/spotify_friends.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class HomePageFriendsSection extends HookConsumerWidget { class HomePageFriendsSection extends HookConsumerWidget {
const HomePageFriendsSection({Key? key}) : super(key: key); const HomePageFriendsSection({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final friendsQuery = useQueries.user.friendActivity(ref); final friendsQuery = ref.watch(friendsProvider);
final friends = friendsQuery.data?.friends ?? FakeData.friends.friends; final friends =
friendsQuery.asData?.value.friends ?? FakeData.friends.friends;
final groupCount = useBreakpointValue( final groupCount = useBreakpointValue(
sm: 3, sm: 3,
@ -51,8 +51,8 @@ class HomePageFriendsSection extends HookConsumerWidget {
}, },
); );
if (!friendsQuery.isLoading && if (friendsQuery.isLoading ||
(!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) { friendsQuery.asData?.value.friends.isEmpty == true) {
return const SliverToBoxAdapter( return const SliverToBoxAdapter(
child: SizedBox.shrink(), child: SizedBox.shrink(),
); );

View File

@ -1,10 +1,8 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/models/spotify_friends.dart';
@ -13,9 +11,9 @@ import 'package:spotube/provider/spotify_provider.dart';
class FriendItem extends HookConsumerWidget { class FriendItem extends HookConsumerWidget {
final SpotifyFriendActivity friend; final SpotifyFriendActivity friend;
const FriendItem({ const FriendItem({
Key? key, super.key,
required this.friend, required this.friend,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -24,7 +22,6 @@ class FriendItem extends HookConsumerWidget {
colorScheme: colorScheme, colorScheme: colorScheme,
) = Theme.of(context); ) = Theme.of(context);
final queryClient = useQueryClient();
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
return Container( return Container(
@ -86,15 +83,11 @@ class FriendItem extends HookConsumerWidget {
..onTap = () async { ..onTap = () async {
context.push( context.push(
"/${friend.track.context.path}", "/${friend.track.context.path}",
extra: !friend.track.context.path extra:
.startsWith("album") !friend.track.context.path.startsWith("album")
? null ? null
: await queryClient.fetchQuery<Album, dynamic>( : await spotify.albums
"album/${friend.track.album.id}", .get(friend.track.context.id),
() => spotify.albums.get(
friend.track.album.id,
),
),
); );
}, },
), ),
@ -110,12 +103,7 @@ class FriendItem extends HookConsumerWidget {
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () async { ..onTap = () async {
final album = final album =
await queryClient.fetchQuery<Album, dynamic>( await spotify.albums.get(friend.track.album.id);
"album/${friend.track.album.id}",
() => spotify.albums.get(
friend.track.album.id,
),
);
if (context.mounted) { if (context.mounted) {
context.push( context.push(
"/album/${friend.track.album.id}", "/album/${friend.track.album.id}",

View File

@ -13,28 +13,26 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/queries/queries.dart';
class HomeGenresSection extends HookConsumerWidget { class HomeGenresSection extends HookConsumerWidget {
const HomeGenresSection({Key? key}) : super(key: key); const HomeGenresSection({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final recommendationMarket = ref.watch( final categoriesQuery = ref.watch(categoriesProvider);
userPreferencesProvider.select((s) => s.recommendationMarket), final categories = useMemoized(
); () =>
final categoriesQuery = categoriesQuery.asData?.value
useQueries.category.listAll(ref, recommendationMarket); .where((c) => (c.icons?.length ?? 0) > 0)
final categories = categoriesQuery.data
?.where((c) => (c.icons?.length ?? 0) > 0)
.take(mediaQuery.mdAndDown ? 6 : 10) .take(mediaQuery.mdAndDown ? 6 : 10)
.toList() ?? .toList() ??
<Category>[]; <Category>[],
[mediaQuery.mdAndDown, categoriesQuery.asData?.value],
);
return SliverMainAxisGroup( return SliverMainAxisGroup(
slivers: [ slivers: [

View File

@ -2,19 +2,19 @@ import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class HomeMadeForUserSection extends HookConsumerWidget { class HomeMadeForUserSection extends HookConsumerWidget {
const HomeMadeForUserSection({Key? key}) : super(key: key); const HomeMadeForUserSection({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); final madeForUser = ref.watch(viewProvider("made-for-x-hub"));
return SliverList.builder( return SliverList.builder(
itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0, itemCount: madeForUser.asData?.value["content"]?["items"]?.length ?? 0,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = madeForUser.data?["content"]?["items"]?[index]; final item = madeForUser.asData?.value["content"]?["items"]?[index];
final playlists = item["content"]?["items"] final playlists = item["content"]?["items"]
?.where((itemL2) => itemL2["type"] == "playlist") ?.where((itemL2) => itemL2["type"] == "playlist")
.map((itemL2) => PlaylistSimple.fromJson(itemL2)) .map((itemL2) => PlaylistSimple.fromJson(itemL2))

View File

@ -1,56 +1,35 @@
import 'package:flutter/material.dart' hide Page; import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class HomeNewReleasesSection extends HookConsumerWidget { class HomeNewReleasesSection extends HookConsumerWidget {
const HomeNewReleasesSection({Key? key}) : super(key: key); const HomeNewReleasesSection({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
final newReleases = useQueries.album.newReleases(ref); final newReleases = ref.watch(albumReleasesProvider);
final userArtistsQuery = useQueries.artist.followedByMeAll(ref); final newReleasesNotifier = ref.read(albumReleasesProvider.notifier);
final userArtists =
userArtistsQuery.data?.map((s) => s.id!).toList() ?? const [];
final albums = useMemoized( final albums = ref.watch(userArtistAlbumReleasesProvider);
() {
final allReleases = newReleases.pages
.whereType<Page<AlbumSimple>>()
.expand((page) => page.items ?? const <AlbumSimple>[])
.map((album) => TypeConversionUtils.simpleAlbum_X_Album(album));
final userArtistReleases = allReleases.where((album) { if (auth == null ||
return album.artists newReleases.isLoading ||
?.any((artist) => userArtists.contains(artist.id!)) == newReleases.asData?.value.items.isEmpty == true) {
true; return const SizedBox.shrink();
}).toList(); }
if (userArtistReleases.isEmpty) return allReleases.toList();
return userArtistReleases;
},
[newReleases.pages],
);
final hasNewReleases = newReleases.hasPageData &&
userArtistsQuery.hasData &&
!newReleases.isLoadingNextPage;
if (auth == null || !hasNewReleases) return const SizedBox.shrink();
return HorizontalPlaybuttonCardView<Album>( return HorizontalPlaybuttonCardView<Album>(
items: albums, items: albums,
title: Text(context.l10n.new_releases), title: Text(context.l10n.new_releases),
isLoadingNextPage: newReleases.isLoadingNextPage, isLoadingNextPage: newReleases.isLoadingNextPage,
hasNextPage: newReleases.hasNextPage, hasNextPage: newReleases.asData?.value.hasMore ?? false,
onFetchMore: newReleases.fetchNext, onFetchMore: newReleasesNotifier.fetchMore,
); );
} }
} }

View File

@ -25,7 +25,7 @@ class MultiSelectField<T> extends HookWidget {
final bool enabled; final bool enabled;
const MultiSelectField({ const MultiSelectField({
Key? key, super.key,
required this.options, required this.options,
required this.selectedOptions, required this.selectedOptions,
required this.getValueForOption, required this.getValueForOption,
@ -36,7 +36,7 @@ class MultiSelectField<T> extends HookWidget {
this.dialogTitle, this.dialogTitle,
this.helperText, this.helperText,
this.enabled = true, this.enabled = true,
}) : super(key: key); });
Widget defaultSelectedOptionBuilder(T option) { Widget defaultSelectedOptionBuilder(T option) {
return Chip( return Chip(
@ -134,14 +134,14 @@ class _MultiSelectDialog<T> extends HookWidget {
final String? helperText; final String? helperText;
const _MultiSelectDialog({ const _MultiSelectDialog({
Key? key, super.key,
required this.dialogTitle, required this.dialogTitle,
required this.options, required this.options,
required this.getValueForOption, required this.getValueForOption,
this.optionBuilder, this.optionBuilder,
this.initialSelection = const [], this.initialSelection = const [],
this.helperText, this.helperText,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -20,12 +20,12 @@ class RecommendationAttributeDials extends HookWidget {
final double base; final double base;
const RecommendationAttributeDials({ const RecommendationAttributeDials({
Key? key, super.key,
required this.values, required this.values,
required this.onChanged, required this.onChanged,
required this.title, required this.title,
this.base = 1, this.base = 1,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -12,12 +12,12 @@ class RecommendationAttributeFields extends HookWidget {
final Map<String, RecommendationAttribute>? presets; final Map<String, RecommendationAttribute>? presets;
const RecommendationAttributeFields({ const RecommendationAttributeFields({
Key? key, super.key,
required this.values, required this.values,
required this.onChanged, required this.onChanged,
required this.title, required this.title,
this.presets, this.presets,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -26,7 +26,7 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
final SelectedItemDisplayType selectedItemDisplayType; final SelectedItemDisplayType selectedItemDisplayType;
const SeedsMultiAutocomplete({ const SeedsMultiAutocomplete({
Key? key, super.key,
required this.seeds, required this.seeds,
required this.fetchSeeds, required this.fetchSeeds,
required this.autocompleteOptionBuilder, required this.autocompleteOptionBuilder,
@ -35,7 +35,7 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
this.inputDecoration, this.inputDecoration,
this.enabled = true, this.enabled = true,
this.selectedItemDisplayType = SelectedItemDisplayType.wrap, this.selectedItemDisplayType = SelectedItemDisplayType.wrap,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -4,16 +4,16 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/image.dart';
class SimpleTrackTile extends HookWidget { class SimpleTrackTile extends HookWidget {
final Track track; final Track track;
final VoidCallback? onDelete; final VoidCallback? onDelete;
const SimpleTrackTile({ const SimpleTrackTile({
Key? key, super.key,
required this.track, required this.track,
this.onDelete, this.onDelete,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -21,8 +21,7 @@ class SimpleTrackTile extends HookWidget {
leading: ClipRRect( leading: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: UniversalImage( child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString( path: (track.album?.images).asUrlString(
track.album?.images,
placeholder: ImagePlaceholder.artist, placeholder: ImagePlaceholder.artist,
), ),
height: 40, height: 40,

View File

@ -2,46 +2,39 @@ import 'package:flutter/material.dart' hide Image;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/album/album_card.dart'; import 'package:spotube/components/album/album_card.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/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class UserAlbums extends HookConsumerWidget { class UserAlbums extends HookConsumerWidget {
const UserAlbums({Key? key}) : super(key: key); const UserAlbums({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
final albumsQuery = useQueries.album.ofMine(ref); final albumsQuery = ref.watch(favoriteAlbumsProvider);
final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier);
final controller = useScrollController(); final controller = useScrollController();
final searchText = useState(''); final searchText = useState('');
final allAlbums = useMemoized(
() => albumsQuery.pages
.expand((element) => element.items ?? <AlbumSimple>[]),
[albumsQuery.pages],
);
final albums = useMemoized(() { final albums = useMemoized(() {
if (searchText.value.isEmpty) { if (searchText.value.isEmpty) {
return allAlbums; return albumsQuery.asData?.value.items ?? [];
} }
return allAlbums return albumsQuery.asData?.value.items
.map((e) => ( .map((e) => (
weightedRatio(e.name!, searchText.value), weightedRatio(e.name!, searchText.value),
e, e,
@ -49,27 +42,29 @@ class UserAlbums extends HookConsumerWidget {
.sorted((a, b) => b.$1.compareTo(a.$1)) .sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50) .where((e) => e.$1 > 50)
.map((e) => e.$2) .map((e) => e.$2)
.toList(); .toList() ??
}, [allAlbums, searchText.value]); [];
}, [albumsQuery.asData?.value, searchText.value]);
if (auth == null) { if (auth == null) {
return const AnonymousFallback(); return const AnonymousFallback();
} }
final theme = Theme.of(context); return SafeArea(
return RefreshIndicator(
onRefresh: () async {
await albumsQuery.refresh();
},
child: SafeArea(
child: Scaffold( child: Scaffold(
appBar: PreferredSize( body: RefreshIndicator(
preferredSize: const Size.fromHeight(50), onRefresh: () async {
child: Padding( ref.invalidate(favoriteAlbumsProvider);
},
child: InterScrollbar(
controller: controller,
child: CustomScrollView(
controller: controller,
slivers: [
SliverAppBar(
floating: true,
flexibleSpace: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ColoredBox(
color: theme.scaffoldBackgroundColor,
child: SearchBar( child: SearchBar(
onChanged: (value) => searchText.value = value, onChanged: (value) => searchText.value = value,
leading: const Icon(SpotubeIcons.filter), leading: const Icon(SpotubeIcons.filter),
@ -77,52 +72,47 @@ class UserAlbums extends HookConsumerWidget {
), ),
), ),
), ),
const SliverGap(10),
Skeletonizer.sliver(
enabled: albumsQuery.isLoading,
child: SliverLayoutBuilder(builder: (context, constrains) {
return SliverGrid.builder(
itemCount: albums.isEmpty ? 6 : albums.length + 1,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: constrains.smAndDown ? 225 : 250,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
), ),
body: SizedBox.expand( itemBuilder: (context, index) {
child: InterScrollbar( if (albums.isNotEmpty && index == albums.length) {
controller: controller, if (albumsQuery.asData?.value.hasMore != true) {
child: SingleChildScrollView( return const SizedBox.shrink();
padding: const EdgeInsets.all(8.0), }
controller: controller,
child: Skeletonizer( return Waypoint(
enabled: albumsQuery.pages.isEmpty,
child: Center(
child: Wrap(
runSpacing: 20,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (albumsQuery.pages.isEmpty)
...List.generate(
10,
(index) => AlbumCard(FakeData.album),
)
else if (albums.isEmpty)
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
for (final album in albums)
AlbumCard(
TypeConversionUtils.simpleAlbum_X_Album(album),
),
if (albums.isNotEmpty && albumsQuery.hasNextPage)
Waypoint(
controller: controller, controller: controller,
isGrid: true, isGrid: true,
onTouchEdge: albumsQuery.fetchNext, onTouchEdge: albumsQueryNotifier.fetchMore,
child: AlbumCard(FakeData.album), child: Skeletonizer(
) enabled: true,
child: AlbumCard(FakeData.albumSimple),
),
);
}
return AlbumCard(
albums.elementAtOrNull(index) ?? FakeData.albumSimple,
);
},
);
}),
),
], ],
), ),
), ),
), ),
), ),
),
),
),
),
); );
} }
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
@ -9,26 +10,27 @@ import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/artist/artist_card.dart'; import 'package:spotube/components/artist/artist_card.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/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class UserArtists extends HookConsumerWidget { class UserArtists extends HookConsumerWidget {
const UserArtists({Key? key}) : super(key: key); const UserArtists({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final auth = ref.watch(authenticationProvider);
final auth = ref.watch(AuthenticationNotifier.provider);
final artistQuery = useQueries.artist.followedByMeAll(ref); final artistQuery = ref.watch(followedArtistsProvider);
final artistQueryNotifier = ref.watch(followedArtistsProvider.notifier);
final searchText = useState(''); final searchText = useState('');
final filteredArtists = useMemoized(() { final filteredArtists = useMemoized(() {
final artists = artistQuery.data ?? []; final artists = artistQuery.asData?.value.items ?? [];
if (searchText.value.isEmpty) { if (searchText.value.isEmpty) {
return artists.toList(); return artists.toList();
@ -42,7 +44,7 @@ class UserArtists extends HookConsumerWidget {
.where((e) => e.$1 > 50) .where((e) => e.$1 > 50)
.map((e) => e.$2) .map((e) => e.$2)
.toList(); .toList();
}, [artistQuery.data, searchText.value]); }, [artistQuery.asData?.value.items, searchText.value]);
final controller = useScrollController(); final controller = useScrollController();
@ -50,71 +52,68 @@ class UserArtists extends HookConsumerWidget {
return const AnonymousFallback(); return const AnonymousFallback();
} }
return Scaffold( return SafeArea(
appBar: PreferredSize( child: Scaffold(
preferredSize: const Size.fromHeight(50), body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(followedArtistsProvider);
},
child: InterScrollbar(
controller: controller,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ColoredBox( child: CustomScrollView(
color: theme.scaffoldBackgroundColor, controller: controller,
child: SearchBar( slivers: [
SliverAppBar(
floating: true,
flexibleSpace: SearchBar(
onChanged: (value) => searchText.value = value, onChanged: (value) => searchText.value = value,
leading: const Icon(SpotubeIcons.filter), leading: const Icon(SpotubeIcons.filter),
hintText: context.l10n.filter_artist, hintText: context.l10n.filter_artist,
), ),
), ),
), const SliverGap(10),
), Skeletonizer.sliver(
backgroundColor: theme.scaffoldBackgroundColor,
body: artistQuery.data?.isEmpty == true
? Padding(
padding: const EdgeInsets.all(20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(width: 10),
Text(context.l10n.loading),
],
),
)
: RefreshIndicator(
onRefresh: () async {
await artistQuery.refresh();
},
child: InterScrollbar(
controller: controller,
child: SingleChildScrollView(
controller: controller,
child: SizedBox(
width: double.infinity,
child: SafeArea(
child: Center(
child: Skeletonizer(
enabled: artistQuery.isLoading, enabled: artistQuery.isLoading,
child: Wrap( child: SliverLayoutBuilder(builder: (context, constrains) {
spacing: 15, return SliverGrid.builder(
runSpacing: 5, itemCount: filteredArtists.isEmpty
children: artistQuery.isLoading ? 6
? List.generate( : filteredArtists.length + 1,
10, (index) => ArtistCard(FakeData.artist)) gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
: filteredArtists.isEmpty maxCrossAxisExtent: 200,
? [ mainAxisExtent: constrains.smAndDown ? 225 : 250,
const Row( crossAxisSpacing: 8,
mainAxisAlignment: mainAxisSpacing: 8,
MainAxisAlignment.center, ),
children: [ itemBuilder: (context, index) {
NotFound(), if (filteredArtists.isNotEmpty &&
index == filteredArtists.length) {
if (artistQuery.asData?.value.hasMore != true) {
return const SizedBox.shrink();
}
return Waypoint(
controller: controller,
isGrid: true,
onTouchEdge: artistQueryNotifier.fetchMore,
child: Skeletonizer(
enabled: true,
child: ArtistCard(FakeData.artist),
),
);
}
return ArtistCard(
filteredArtists.elementAtOrNull(index) ??
FakeData.artist,
);
},
);
}),
),
], ],
)
]
: filteredArtists
.mapIndexed((index, artist) =>
ArtistCard(artist))
.toList(),
),
),
),
), ),
), ),
), ),

View File

@ -7,7 +7,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
class UserDownloads extends HookConsumerWidget { class UserDownloads extends HookConsumerWidget {
const UserDownloads({Key? key}) : super(key: key); const UserDownloads({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {

View File

@ -4,18 +4,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/download_manager/download_status.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadItem extends HookConsumerWidget { class DownloadItem extends HookConsumerWidget {
final Track track; final Track track;
const DownloadItem({ const DownloadItem({
Key? key, super.key,
required this.track, required this.track,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -51,16 +52,15 @@ class DownloadItem extends HookConsumerWidget {
child: UniversalImage( child: UniversalImage(
height: 40, height: 40,
width: 40, width: 40,
path: TypeConversionUtils.image_X_UrlString( path: (track.album?.images).asUrlString(
track.album?.images,
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
), ),
), ),
), ),
), ),
title: Text(track.name ?? ''), title: Text(track.name ?? ''),
subtitle: TypeConversionUtils.artists_X_ClickableArtists( subtitle: ArtistLink(
track.artists ?? <Artist>[], artists: track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start, mainAxisAlignment: WrapAlignment.start,
), ),
trailing: isQueryingSourceInfo trailing: isQueryingSourceInfo

View File

@ -21,12 +21,14 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_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_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; // ignore: depend_on_referenced_packages
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
const supportedAudioTypes = [ const supportedAudioTypes = [
@ -111,7 +113,7 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
final tracks = filesWithMetadata final tracks = filesWithMetadata
.map( .map(
(fileWithMetadata) => LocalTrack.fromTrack( (fileWithMetadata) => LocalTrack.fromTrack(
track: TypeConversionUtils.localTrack_X_Track( track: Track().fromFile(
fileWithMetadata["file"], fileWithMetadata["file"],
metadata: fileWithMetadata["metadata"], metadata: fileWithMetadata["metadata"],
art: fileWithMetadata["art"], art: fileWithMetadata["art"],
@ -129,15 +131,15 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
}); });
class UserLocalTracks extends HookConsumerWidget { class UserLocalTracks extends HookConsumerWidget {
const UserLocalTracks({Key? key}) : super(key: key); const UserLocalTracks({super.key});
Future<void> playLocalTracks( Future<void> playLocalTracks(
WidgetRef ref, WidgetRef ref,
List<LocalTrack> tracks, { List<LocalTrack> tracks, {
LocalTrack? currentTrack, LocalTrack? currentTrack,
}) async { }) async {
final playlist = ref.read(ProxyPlaylistNotifier.provider); final playlist = ref.read(proxyPlaylistProvider);
final playback = ref.read(ProxyPlaylistNotifier.notifier); final playback = ref.read(proxyPlaylistProvider.notifier);
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
final isPlaylistPlaying = playlist.containsTracks(tracks); final isPlaylistPlaying = playlist.containsTracks(tracks);
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
@ -156,10 +158,10 @@ class UserLocalTracks extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final sortBy = useState<SortBy>(SortBy.none); final sortBy = useState<SortBy>(SortBy.none);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final trackSnapshot = ref.watch(localTracksProvider); final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying = final isPlaylistPlaying =
playlist.containsTracks(trackSnapshot.value ?? []); playlist.containsTracks(trackSnapshot.asData?.value ?? []);
final searchController = useTextEditingController(); final searchController = useTextEditingController();
useValueListenable(searchController); useValueListenable(searchController);
@ -174,19 +176,16 @@ class UserLocalTracks extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 10), const SizedBox(width: 5),
FilledButton( FilledButton(
onPressed: trackSnapshot.value != null onPressed: trackSnapshot.asData?.value != null
? () async { ? () async {
if (trackSnapshot.value?.isNotEmpty == true) { if (trackSnapshot.asData?.value.isNotEmpty == true) {
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
await playLocalTracks( await playLocalTracks(
ref, ref,
trackSnapshot.value!, trackSnapshot.asData!.value,
); );
} else {
// TODO: Remove stop capability
// playlistNotifier.stop();
} }
} }
} }
@ -213,11 +212,11 @@ class UserLocalTracks extends HookConsumerWidget {
sortBy.value = value; sortBy.value = value;
}, },
), ),
const SizedBox(width: 10), const SizedBox(width: 5),
FilledButton( FilledButton(
child: const Icon(SpotubeIcons.refresh), child: const Icon(SpotubeIcons.refresh),
onPressed: () { onPressed: () {
ref.refresh(localTracksProvider); ref.invalidate(localTracksProvider);
}, },
) )
], ],
@ -242,7 +241,7 @@ class UserLocalTracks extends HookConsumerWidget {
return sortedTracks return sortedTracks
.map((e) => ( .map((e) => (
weightedRatio( weightedRatio(
"${e.name} - ${TypeConversionUtils.artists_X_String<Artist>(e.artists ?? [])}", "${e.name} - ${e.artists?.asString() ?? ""}",
searchController.text, searchController.text,
), ),
e, e,
@ -269,7 +268,7 @@ class UserLocalTracks extends HookConsumerWidget {
return Expanded( return Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () async { onRefresh: () async {
ref.refresh(localTracksProvider); ref.invalidate(localTracksProvider);
}, },
child: InterScrollbar( child: InterScrollbar(
controller: controller, controller: controller,
@ -282,12 +281,17 @@ class UserLocalTracks extends HookConsumerWidget {
trackSnapshot.isLoading ? 5 : filteredTracks.length, trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (trackSnapshot.isLoading) { if (trackSnapshot.isLoading) {
return TrackTile(track: FakeData.track, index: index); return TrackTile(
playlist: playlist,
track: FakeData.track,
index: index,
);
} }
final track = filteredTracks[index]; final track = filteredTracks[index];
return TrackTile( return TrackTile(
index: index, index: index,
playlist: playlist,
track: track, track: track,
userPlaylist: false, userPlaylist: false,
onTap: () async { onTap: () async {
@ -310,8 +314,11 @@ class UserLocalTracks extends HookConsumerWidget {
enabled: true, enabled: true,
child: ListView.builder( child: ListView.builder(
itemCount: 5, itemCount: 5,
itemBuilder: (context, index) => itemBuilder: (context, index) => TrackTile(
TrackTile(track: FakeData.track, index: index), track: FakeData.track,
index: index,
playlist: playlist,
),
), ),
), ),
), ),

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Image;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@ -17,24 +18,21 @@ import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/utils/platform.dart';
class UserPlaylists extends HookConsumerWidget { class UserPlaylists extends HookConsumerWidget {
const UserPlaylists({Key? key}) : super(key: key); const UserPlaylists({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final searchText = useState(''); final searchText = useState('');
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
final playlistsQuery = useQueries.playlist.ofMine(ref); final playlistsQuery = ref.watch(favoritePlaylistsProvider);
final playlistsQueryNotifier =
final pagePlaylists = useMemoized( ref.watch(favoritePlaylistsProvider.notifier);
() => playlistsQuery.pages
.expand((page) => page.items?.toList() ?? <PlaylistSimple>[]),
[playlistsQuery.pages],
);
final likedTracksPlaylist = useMemoized( final likedTracksPlaylist = useMemoized(
() => PlaylistSimple() () => PlaylistSimple()
@ -58,12 +56,12 @@ class UserPlaylists extends HookConsumerWidget {
if (searchText.value.isEmpty) { if (searchText.value.isEmpty) {
return [ return [
likedTracksPlaylist, likedTracksPlaylist,
...pagePlaylists, ...?playlistsQuery.asData?.value.items,
]; ];
} }
return [ return [
likedTracksPlaylist, likedTracksPlaylist,
...pagePlaylists, ...?playlistsQuery.asData?.value.items,
] ]
.map((e) => (weightedRatio(e.name!, searchText.value), e)) .map((e) => (weightedRatio(e.name!, searchText.value), e))
.sorted((a, b) => b.$1.compareTo(a.$1)) .sorted((a, b) => b.$1.compareTo(a.$1))
@ -71,7 +69,7 @@ class UserPlaylists extends HookConsumerWidget {
.map((e) => e.$2) .map((e) => e.$2)
.toList(); .toList();
}, },
[pagePlaylists, searchText.value], [playlistsQuery, searchText.value],
); );
final controller = useScrollController(); final controller = useScrollController();
@ -81,30 +79,33 @@ class UserPlaylists extends HookConsumerWidget {
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: playlistsQuery.refresh, onRefresh: () async {
ref.invalidate(favoritePlaylistsProvider);
},
child: SafeArea( child: SafeArea(
child: InterScrollbar( child: InterScrollbar(
controller: controller, controller: controller,
child: CustomScrollView( child: CustomScrollView(
controller: controller, controller: controller,
slivers: [ slivers: [
SliverToBoxAdapter( SliverAppBar(
child: Column( floating: true,
mainAxisSize: MainAxisSize.min, flexibleSpace: Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 8),
Padding(
padding: const EdgeInsets.all(10),
child: SearchBar( child: SearchBar(
onChanged: (value) => searchText.value = value, onChanged: (value) => searchText.value = value,
hintText: context.l10n.filter_playlists, hintText: context.l10n.filter_playlists,
leading: const Icon(SpotubeIcons.filter), leading: const Icon(SpotubeIcons.filter),
), ),
), ),
Row( bottom: PreferredSize(
preferredSize:
Size.fromHeight(kIsDesktop ? 35 : kToolbarHeight),
child: Row(
children: [ children: [
const SizedBox(width: 10), const Gap(10),
const PlaylistCreateDialogButton(), const PlaylistCreateDialogButton(),
const SizedBox(width: 10), const Gap(10),
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(SpotubeIcons.magic), icon: const Icon(SpotubeIcons.magic),
label: Text(context.l10n.generate_playlist), label: Text(context.l10n.generate_playlist),
@ -112,15 +113,12 @@ class UserPlaylists extends HookConsumerWidget {
GoRouter.of(context).push("/library/generate"); GoRouter.of(context).push("/library/generate");
}, },
), ),
const SizedBox(width: 10), const Gap(10),
],
),
], ],
), ),
), ),
const SliverToBoxAdapter(
child: SizedBox(height: 10),
), ),
const SliverGap(10),
SliverLayoutBuilder(builder: (context, constrains) { SliverLayoutBuilder(builder: (context, constrains) {
return SliverGrid.builder( return SliverGrid.builder(
itemCount: playlists.isEmpty ? 6 : playlists.length + 1, itemCount: playlists.isEmpty ? 6 : playlists.length + 1,
@ -132,14 +130,14 @@ class UserPlaylists extends HookConsumerWidget {
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (playlists.isNotEmpty && index == playlists.length) { if (playlists.isNotEmpty && index == playlists.length) {
if (!playlistsQuery.hasNextPage) { if (playlistsQuery.asData?.value.hasMore != true) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return Waypoint( return Waypoint(
controller: controller, controller: controller,
isGrid: true, isGrid: true,
onTouchEdge: playlistsQuery.fetchNext, onTouchEdge: playlistsQueryNotifier.fetchMore,
child: Skeletonizer( child: Skeletonizer(
enabled: true, enabled: true,
child: PlaylistCard(FakeData.playlistSimple), child: PlaylistCard(FakeData.playlistSimple),

View File

@ -17,7 +17,7 @@ class ZoomControls extends HookWidget {
final String unit; final String unit;
const ZoomControls({ const ZoomControls({
Key? key, super.key,
required this.value, required this.value,
required this.onChanged, required this.onChanged,
this.min, this.min,
@ -27,7 +27,7 @@ class ZoomControls extends HookWidget {
this.decreaseIcon = const Icon(SpotubeIcons.zoomOut), this.decreaseIcon = const Icon(SpotubeIcons.zoomOut),
this.direction = Axis.horizontal, this.direction = Axis.horizontal,
this.unit = "%", this.unit = "%",
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_actions.dart'; import 'package:spotube/components/player/player_actions.dart';
@ -13,40 +12,44 @@ import 'package:spotube/components/player/player_queue.dart';
import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/components/player/volume_slider.dart';
import 'package:spotube/components/shared/animated_gradient.dart'; import 'package:spotube/components/shared/animated_gradient.dart';
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class PlayerView extends HookConsumerWidget { class PlayerView extends HookConsumerWidget {
final PanelController panelController; final PanelController panelController;
final ScrollController scrollController; final ScrollController scrollController;
const PlayerView({ const PlayerView({
Key? key, super.key,
required this.panelController, required this.panelController,
required this.scrollController, required this.scrollController,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( final sourcedCurrentTrack = ref.watch(activeSourcedTrackProvider);
(value) => value.activeTrack, final currentActiveTrack =
)); ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack));
final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( final currentTrack = sourcedCurrentTrack ?? currentActiveTrack;
(value) => value.activeTrack is LocalTrack, final isLocalTrack = currentTrack is LocalTrack;
));
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
useEffect(() { useEffect(() {
@ -59,8 +62,7 @@ class PlayerView extends HookConsumerWidget {
}, [mediaQuery.lgAndUp]); }, [mediaQuery.lgAndUp]);
String albumArt = useMemoized( String albumArt = useMemoized(
() => TypeConversionUtils.image_X_UrlString( () => (currentTrack?.album?.images).asUrlString(
currentTrack?.album?.images,
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
), ),
[currentTrack?.album?.images], [currentTrack?.album?.images],
@ -96,6 +98,7 @@ class PlayerView extends HookConsumerWidget {
final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; final topPadding = MediaQueryData.fromView(View.of(context)).padding.top;
// ignore: deprecated_member_use
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
await panelController.close(); await panelController.close();
@ -149,7 +152,7 @@ class PlayerView extends HookConsumerWidget {
label: Text(context.l10n.song_link), label: Text(context.l10n.song_link),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: bodyTextColor, foregroundColor: bodyTextColor,
padding: EdgeInsets.zero, padding: const EdgeInsets.symmetric(horizontal: 10),
), ),
onPressed: () { onPressed: () {
final url = final url =
@ -239,19 +242,15 @@ class PlayerView extends HookConsumerWidget {
), ),
if (isLocalTrack) if (isLocalTrack)
Text( Text(
TypeConversionUtils.artists_X_String< currentTrack.artists?.asString() ?? "",
Artist>(
currentTrack?.artists ?? [],
),
style: theme.textTheme.bodyMedium!.copyWith( style: theme.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: bodyTextColor, color: bodyTextColor,
), ),
) )
else else
TypeConversionUtils ArtistLink(
.artists_X_ClickableArtists( artists: currentTrack?.artists ?? [],
currentTrack?.artists ?? [],
textStyle: textStyle:
theme.textTheme.bodyMedium!.copyWith( theme.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -307,10 +306,24 @@ class PlayerView extends HookConsumerWidget {
.height * .height *
.7, .7,
), ),
builder: (context) { builder: (context) => Consumer(
return const PlayerQueue( builder: (context, ref, _) {
floating: false); final playlist = ref.watch(
proxyPlaylistProvider,
);
final playlistNotifier =
ref.read(
proxyPlaylistProvider
.notifier,
);
return PlayerQueue
.fromProxyPlaylistNotifier(
floating: false,
playlist: playlist,
notifier: playlistNotifier,
);
}, },
),
); );
} }
: null), : null),
@ -368,11 +381,21 @@ class PlayerView extends HookConsumerWidget {
enabledThumbRadius: 8, enabledThumbRadius: 8,
), ),
), ),
child: const Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding:
child: VolumeSlider( const EdgeInsets.symmetric(horizontal: 16),
child: Consumer(builder: (context, ref, _) {
final volume = ref.watch(volumeProvider);
return VolumeSlider(
fullWidth: true, fullWidth: true,
), value: volume,
onChanged: (value) {
ref
.read(volumeProvider.notifier)
.setVolume(value);
},
);
}),
), ),
), ),
], ],

View File

@ -3,12 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/sibling_tracks_sheet.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/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
@ -17,7 +16,6 @@ import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/sleep_timer_provider.dart'; import 'package:spotube/provider/sleep_timer_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerActions extends HookConsumerWidget { class PlayerActions extends HookConsumerWidget {
final MainAxisAlignment mainAxisAlignment; final MainAxisAlignment mainAxisAlignment;
@ -29,14 +27,13 @@ class PlayerActions extends HookConsumerWidget {
this.floatingQueue = true, this.floatingQueue = true,
this.showQueue = true, this.showQueue = true,
this.extraActions, this.extraActions,
Key? key, super.key,
}) : super(key: key); });
final logger = getLogger(PlayerActions); final logger = getLogger(PlayerActions);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.of(context); final playlist = ref.watch(proxyPlaylistProvider);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final isLocalTrack = playlist.activeTrack is LocalTrack; final isLocalTrack = playlist.activeTrack is LocalTrack;
ref.watch(downloadManagerProvider); ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider.notifier);
@ -49,19 +46,17 @@ class PlayerActions extends HookConsumerWidget {
]); ]);
final localTracks = [] /* ref.watch(localTracksProvider).value */; final localTracks = [] /* ref.watch(localTracksProvider).value */;
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
final sleepTimer = ref.watch(SleepTimerNotifier.provider); final sleepTimer = ref.watch(sleepTimerProvider);
final sleepTimerNotifier = ref.watch(SleepTimerNotifier.notifier); final sleepTimerNotifier = ref.watch(sleepTimerProvider.notifier);
final isDownloaded = useMemoized(() { final isDownloaded = useMemoized(() {
return localTracks.any( return localTracks.any(
(element) => (element) =>
element.name == playlist.activeTrack?.name && element.name == playlist.activeTrack?.name &&
element.album?.name == playlist.activeTrack?.album?.name && element.album?.name == playlist.activeTrack?.album?.name &&
TypeConversionUtils.artists_X_String<Artist>( element.artists?.asString() ==
element.artists ?? []) == playlist.activeTrack?.artists?.asString(),
TypeConversionUtils.artists_X_String<Artist>(
playlist.activeTrack?.artists ?? []),
) == ) ==
true; true;
}, [localTracks, playlist.activeTrack]); }, [localTracks, playlist.activeTrack]);

View File

@ -21,8 +21,8 @@ class PlayerControls extends HookConsumerWidget {
PlayerControls({ PlayerControls({
this.palette, this.palette,
this.compact = false, this.compact = false,
Key? key, super.key,
}) : super(key: key); });
final logger = getLogger(PlayerControls); final logger = getLogger(PlayerControls);
@ -43,8 +43,8 @@ class PlayerControls extends HookConsumerWidget {
SeekIntent: SeekAction(), SeekIntent: SeekAction(),
}, },
[]); []);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
onPressed: playlist.isFetching == true onPressed: playlist.isFetching == true
? null ? null
: () async { : () async {
switch (await audioPlayer.loopMode) { audioPlayer.setLoopMode(
case PlaybackLoopMode.all: switch (loopMode) {
audioPlayer PlaybackLoopMode.all =>
.setLoopMode(PlaybackLoopMode.one); PlaybackLoopMode.one,
break; PlaybackLoopMode.one =>
case PlaybackLoopMode.one: PlaybackLoopMode.none,
audioPlayer PlaybackLoopMode.none =>
.setLoopMode(PlaybackLoopMode.none); PlaybackLoopMode.all,
break; },
case PlaybackLoopMode.none: );
audioPlayer
.setLoopMode(PlaybackLoopMode.all);
break;
}
}, },
); );
}), }),

View File

@ -19,16 +19,15 @@ class PlayerOverlay extends HookConsumerWidget {
const PlayerOverlay({ const PlayerOverlay({
required this.albumArt, required this.albumArt,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final canShow = ref.watch( final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
ProxyPlaylistNotifier.provider.select((s) => s.active != null), final playlist = ref.watch(proxyPlaylistProvider);
); final canShow = playlist.activeTrack != null;
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
@ -115,7 +114,7 @@ class PlayerOverlay extends HookConsumerWidget {
width: double.infinity, width: double.infinity,
color: Colors.transparent, color: Colors.transparent,
child: PlayerTrackDetails( child: PlayerTrackDetails(
albumArt: albumArt, track: playlist.activeTrack,
color: textColor, color: textColor,
), ),
), ),

View File

@ -5,30 +5,55 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/not_found.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/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerQueue extends HookConsumerWidget { class PlayerQueue extends HookConsumerWidget {
final bool floating; final bool floating;
final ProxyPlaylist playlist;
final Future<void> Function(Track track) onJump;
final Future<void> Function(String trackId) onRemove;
final Future<void> Function(int oldIndex, int newIndex) onReorder;
final Future<void> Function() onStop;
const PlayerQueue({ const PlayerQueue({
this.floating = true, this.floating = true,
Key? key, required this.playlist,
}) : super(key: key); required this.onJump,
required this.onRemove,
required this.onReorder,
required this.onStop,
super.key,
});
PlayerQueue.fromProxyPlaylistNotifier({
this.floating = true,
required this.playlist,
required ProxyPlaylistNotifier notifier,
super.key,
}) : onJump = notifier.jumpToTrack,
onRemove = notifier.removeTrack,
onReorder = notifier.moveTrack,
onStop = notifier.stop;
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final mediaQuery = MediaQuery.of(context);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final controller = useAutoScrollController(); final controller = useAutoScrollController();
final searchText = useState(''); final searchText = useState('');
@ -44,7 +69,6 @@ class PlayerQueue extends HookConsumerWidget {
topRight: Radius.circular(10), topRight: Radius.circular(10),
); );
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final headlineColor = theme.textTheme.headlineSmall?.color; final headlineColor = theme.textTheme.headlineSmall?.color;
final filteredTracks = useMemoized( final filteredTracks = useMemoized(
@ -55,7 +79,7 @@ class PlayerQueue extends HookConsumerWidget {
return tracks return tracks
.map((e) => ( .map((e) => (
weightedRatio( weightedRatio(
'${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}', '${e.name!} - ${e.artists?.asString() ?? ""}',
searchText.value, searchText.value,
), ),
e e
@ -83,6 +107,8 @@ class PlayerQueue extends HookConsumerWidget {
return const NotFound(vertical: true); return const NotFound(vertical: true);
} }
return LayoutBuilder(
builder: (context, constrains) {
return ClipRRect( return ClipRRect(
borderRadius: borderRadius, borderRadius: borderRadius,
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
@ -109,10 +135,15 @@ class PlayerQueue extends HookConsumerWidget {
searchText.value = ''; searchText.value = '';
} }
}, },
child: Column( child: InterScrollbar(
children: [ controller: controller,
child: CustomScrollView(
controller: controller,
slivers: [
if (!floating) if (!floating)
Container( SliverToBoxAdapter(
child: Center(
child: Container(
height: 5, height: 5,
width: 100, width: 100,
margin: const EdgeInsets.only(bottom: 5, top: 2), margin: const EdgeInsets.only(bottom: 5, top: 2),
@ -121,22 +152,39 @@ class PlayerQueue extends HookConsumerWidget {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
), ),
Row( ),
crossAxisAlignment: CrossAxisAlignment.center, ),
mainAxisAlignment: MainAxisAlignment.center, SliverAppBar(
children: [ floating: true,
if (mediaQuery.mdAndUp || !isSearching.value) ...[ pinned: false,
const SizedBox(width: 10), snap: false,
Text( backgroundColor: Colors.transparent,
context.l10n.tracks_in_queue(tracks.length), elevation: 0,
automaticallyImplyLeading: false,
title: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
child: SizedBox(
height: kToolbarHeight,
child: mediaQuery.mdAndUp || !isSearching.value
? Align(
alignment: Alignment.centerLeft,
child: Text(
context.l10n
.tracks_in_queue(tracks.length),
style: TextStyle( style: TextStyle(
color: headlineColor, color: headlineColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 18, fontSize: 18,
), ),
), ),
const Spacer(), )
], : null,
),
),
actions: [
if (mediaQuery.mdAndUp || isSearching.value) if (mediaQuery.mdAndUp || isSearching.value)
TextField( TextField(
onChanged: (value) { onChanged: (value) {
@ -179,9 +227,10 @@ class PlayerQueue extends HookConsumerWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
FilledButton( FilledButton(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: backgroundColor: theme.scaffoldBackgroundColor
theme.scaffoldBackgroundColor.withOpacity(0.5), .withOpacity(0.5),
foregroundColor: theme.textTheme.headlineSmall?.color, foregroundColor:
theme.textTheme.headlineSmall?.color,
), ),
child: Row( child: Row(
children: [ children: [
@ -191,7 +240,7 @@ class PlayerQueue extends HookConsumerWidget {
], ],
), ),
onPressed: () { onPressed: () {
playlistNotifier.stop(); onStop();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
@ -199,17 +248,10 @@ class PlayerQueue extends HookConsumerWidget {
], ],
], ],
), ),
const SizedBox(height: 10), const SliverGap(10),
if (!isSearching.value && searchText.value.isEmpty) SliverReorderableList(
Flexible( onReorder: onReorder,
child: ReorderableListView.builder( itemCount: filteredTracks.length,
onReorder: (oldIndex, newIndex) {
playlistNotifier.moveTrack(oldIndex, newIndex);
},
scrollController: controller,
itemCount: tracks.length,
shrinkWrap: true,
buildDefaultDragHandles: false,
onReorderStart: (index) { onReorderStart: (index) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
@ -217,27 +259,34 @@ class PlayerQueue extends HookConsumerWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
itemBuilder: (context, i) { itemBuilder: (context, i) {
final track = tracks.elementAt(i); final track = filteredTracks.elementAt(i);
return AutoScrollTag( return AutoScrollTag(
key: ValueKey(i), key: ValueKey<int>(i),
controller: controller, controller: controller,
index: i, index: i,
child: Padding( child: Material(
padding: color: Colors.transparent,
const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile( child: TrackTile(
playlist: playlist,
index: i, index: i,
track: track, track: track,
onTap: () async { onTap: () async {
if (playlist.activeTrack?.id == track.id) { if (playlist.activeTrack?.id == track.id) {
return; return;
} }
await playlistNotifier.jumpToTrack(track); await onJump(track);
}, },
leadingActions: [ leadingActions: [
ReorderableDragStartListener( if (!isSearching.value &&
searchText.value.isEmpty)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ReorderableDragStartListener(
index: i, index: i,
child: const Icon(SpotubeIcons.dragHandle), child: const Icon(
SpotubeIcons.dragHandle,
),
),
), ),
], ],
), ),
@ -245,39 +294,15 @@ class PlayerQueue extends HookConsumerWidget {
); );
}, },
), ),
) const SliverGap(100),
else
Flexible(
child: InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
itemCount: filteredTracks.length,
itemBuilder: (context, i) {
final track = filteredTracks.elementAt(i);
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
),
);
},
),
),
),
], ],
), ),
), ),
), ),
), ),
),
);
},
); );
} }
} }

View File

@ -4,23 +4,24 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerTrackDetails extends HookConsumerWidget { class PlayerTrackDetails extends HookConsumerWidget {
final String? albumArt;
final Color? color; final Color? color;
const PlayerTrackDetails({Key? key, this.albumArt, this.color}) final Track? track;
: super(key: key); const PlayerTrackDetails({super.key, this.color, this.track});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final playback = ref.watch(ProxyPlaylistNotifier.provider); final playback = ref.watch(proxyPlaylistProvider);
return Row( return Row(
children: [ children: [
@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: UniversalImage( child: UniversalImage(
path: albumArt ?? "", path: (track?.album?.images)
.asUrlString(placeholder: ImagePlaceholder.albumArt),
placeholder: Assets.albumPlaceholder.path, placeholder: Assets.albumPlaceholder.path,
), ),
), ),
@ -55,9 +57,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
), ),
), ),
Text( Text(
TypeConversionUtils.artists_X_String<Artist>( playback.activeTrack?.artists?.asString() ?? "",
playback.activeTrack?.artists ?? [],
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall!.copyWith(color: color), style: theme.textTheme.bodySmall!.copyWith(color: color),
) )
@ -76,8 +76,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold, color: color), style: TextStyle(fontWeight: FontWeight.bold, color: color),
), ),
TypeConversionUtils.artists_X_ClickableArtists( ArtistLink(
playback.activeTrack?.artists ?? [], artists: playback.activeTrack?.artists ?? [],
onRouteChange: (route) { onRouteChange: (route) {
ServiceUtils.push(context, route); ServiceUtils.push(context, route);
}, },

View File

@ -4,17 +4,18 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/hooks/utils/use_debounce.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/user_preferences/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/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart';
@ -24,7 +25,6 @@ import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
final sourceInfoToIconMap = { final sourceInfoToIconMap = {
YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)), YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)),
@ -45,29 +45,30 @@ final sourceInfoToIconMap = {
class SiblingTracksSheet extends HookConsumerWidget { class SiblingTracksSheet extends HookConsumerWidget {
final bool floating; final bool floating;
const SiblingTracksSheet({ const SiblingTracksSheet({
Key? key, super.key,
this.floating = true, this.floating = true,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final preferences = ref.watch(userPreferencesProvider); final preferences = ref.watch(userPreferencesProvider);
final isSearching = useState(false); final isSearching = useState(false);
final searchMode = useState(preferences.searchMode); final searchMode = useState(preferences.searchMode);
final activeTrackNotifier = ref.watch(activeSourcedTrackProvider.notifier);
final activeTrack =
ref.watch(activeSourcedTrackProvider) ?? playlist.activeTrack;
final title = ServiceUtils.getTitle( final title = ServiceUtils.getTitle(
playlist.activeTrack?.name ?? "", activeTrack?.name ?? "",
artists: artists: activeTrack?.artists?.map((e) => e.name!).toList() ?? [],
playlist.activeTrack?.artists?.map((e) => e.name!).toList() ?? [],
onlyCleanArtist: true, onlyCleanArtist: true,
).trim(); ).trim();
final defaultSearchTerm = final defaultSearchTerm =
"$title - ${TypeConversionUtils.artists_X_String<Artist>(playlist.activeTrack?.artists ?? [])}"; "$title - ${activeTrack?.artists?.asString() ?? ""}";
final searchController = useTextEditingController( final searchController = useTextEditingController(
text: defaultSearchTerm, text: defaultSearchTerm,
); );
@ -91,8 +92,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
return siblingType.info; return siblingType.info;
})); }));
final activeSourceInfo = final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo;
(playlist.activeTrack! as SourcedTrack).sourceInfo;
return results return results
..removeWhere((element) => element.id == activeSourceInfo.id) ..removeWhere((element) => element.id == activeSourceInfo.id)
@ -112,8 +112,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
return siblingType.info; return siblingType.info;
}), }),
); );
final activeSourceInfo = final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo;
(playlist.activeTrack! as SourcedTrack).sourceInfo;
return searchResults return searchResults
..removeWhere((element) => element.id == activeSourceInfo.id) ..removeWhere((element) => element.id == activeSourceInfo.id)
..insert( ..insert(
@ -124,18 +123,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
}, [ }, [
searchTerm, searchTerm,
searchMode.value, searchMode.value,
playlist.activeTrack, activeTrack,
preferences.audioSource, preferences.audioSource,
]); ]);
final siblings = useMemoized( final siblings = useMemoized(
() => playlist.isFetching == false () => playlist.isFetching == false
? [ ? [
(playlist.activeTrack as SourcedTrack).sourceInfo, (activeTrack as SourcedTrack).sourceInfo,
...(playlist.activeTrack as SourcedTrack).siblings, ...activeTrack.siblings,
] ]
: <SourceInfo>[], : <SourceInfo>[],
[playlist.isFetching, playlist.activeTrack], [playlist.isFetching, activeTrack],
); );
final borderRadius = floating final borderRadius = floating
@ -146,12 +145,11 @@ class SiblingTracksSheet extends HookConsumerWidget {
); );
useEffect(() { useEffect(() {
if (playlist.activeTrack is SourcedTrack && if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) {
(playlist.activeTrack as SourcedTrack).siblings.isEmpty) { activeTrackNotifier.populateSibling();
playlistNotifier.populateSibling();
} }
return null; return null;
}, [playlist.activeTrack]); }, [activeTrack]);
final itemBuilder = useCallback( final itemBuilder = useCallback(
(SourceInfo sourceInfo) { (SourceInfo sourceInfo) {
@ -178,20 +176,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
), ),
enabled: playlist.isFetching != true, enabled: playlist.isFetching != true,
selected: playlist.isFetching != true && selected: playlist.isFetching != true &&
sourceInfo.id == sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id,
(playlist.activeTrack as SourcedTrack).sourceInfo.id,
selectedTileColor: theme.popupMenuTheme.color, selectedTileColor: theme.popupMenuTheme.color,
onTap: () { onTap: () {
if (playlist.isFetching == false && if (playlist.isFetching == false &&
sourceInfo.id != sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) {
(playlist.activeTrack as SourcedTrack).sourceInfo.id) { activeTrackNotifier.swapSibling(sourceInfo);
playlistNotifier.swapSibling(sourceInfo);
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
); );
}, },
[playlist.isFetching, playlist.activeTrack, siblings], [playlist.isFetching, activeTrack, siblings],
); );
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);

View File

@ -3,37 +3,39 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/provider/volume_provider.dart';
class VolumeSlider extends HookConsumerWidget { class VolumeSlider extends HookConsumerWidget {
final bool fullWidth; final bool fullWidth;
final double value;
final ValueChanged<double> onChanged;
const VolumeSlider({ const VolumeSlider({
Key? key, super.key,
this.fullWidth = false, this.fullWidth = false,
}) : super(key: key); required this.value,
required this.onChanged,
});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final volume = ref.watch(volumeProvider);
final volumeNotifier = ref.watch(volumeProvider.notifier);
var slider = Listener( var slider = Listener(
onPointerSignal: (event) async { onPointerSignal: (event) async {
if (event is PointerScrollEvent) { if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) { if (event.scrollDelta.dy > 0) {
final value = volume - .2; final newValue = value - .2;
volumeNotifier.setVolume(value < 0 ? 0 : value); onChanged(newValue < 0 ? 0 : newValue);
} else { } else {
final value = volume + .2; final newValue = value + .2;
volumeNotifier.setVolume(value > 1 ? 1 : value); onChanged(newValue > 1 ? 1 : newValue);
} }
} }
}, },
child: Slider( child: Slider(
min: 0, min: 0,
max: 1, max: 1,
value: volume, value: value,
onChanged: volumeNotifier.setVolume, onChanged: onChanged,
), ),
); );
return Row( return Row(
@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget {
children: [ children: [
IconButton( IconButton(
icon: Icon( icon: Icon(
volume == 0 value == 0
? SpotubeIcons.volumeMute ? SpotubeIcons.volumeMute
: volume <= 0.2 : value <= 0.2
? SpotubeIcons.volumeLow ? SpotubeIcons.volumeLow
: volume <= 0.6 : value <= 0.6
? SpotubeIcons.volumeMedium ? SpotubeIcons.volumeMedium
: SpotubeIcons.volumeHigh, : SpotubeIcons.volumeHigh,
size: 16, size: 16,
), ),
onPressed: () { onPressed: () {
if (volume == 0) { if (value == 0) {
volumeNotifier.setVolume(1); onChanged(1);
} else { } else {
volumeNotifier.setVolume(0); onChanged(0);
} }
}, },
), ),

View File

@ -1,77 +1,59 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistCard extends HookConsumerWidget { class PlaylistCard extends HookConsumerWidget {
final PlaylistSimple playlist; final PlaylistSimple playlist;
const PlaylistCard( const PlaylistCard(
this.playlist, { this.playlist, {
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); final playlistQueue = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final queryClient = QueryClient.of(context);
final tracks = useState<List<TrackSimple>?>(null);
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
() => playlistQueue.containsCollection(playlist.id!), () => playlistQueue.containsCollection(playlist.id!),
[playlistQueue, playlist.id], [playlistQueue, playlist.id],
); );
final updating = useState(false); final updating = useState(false);
final spotify = ref.watch(spotifyProvider); final me = ref.watch(meProvider);
final me = useQueries.user.me(ref);
Future<List<Track>> fetchAllTracks() async { Future<List<Track>> fetchAllTracks() async {
if (playlist.id == 'user-liked-tracks') { if (playlist.id == 'user-liked-tracks') {
return await queryClient.fetchQuery( return await ref.read(likedTracksProvider.future);
"user-liked-tracks",
() => useQueries.playlist.likedTracks(spotify),
) ??
[];
} }
final query = queryClient.createInfiniteQuery<List<Track>, dynamic, int>( await ref.read(playlistTracksProvider(playlist.id!).future);
"playlist-tracks/${playlist.id}",
(page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!),
initialPage: 0,
nextPage: useQueries.playlist.tracksOfQueryNextPage,
);
return await query.fetchAllTracks( return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll();
getAllTracks: () async {
final res =
await spotify.playlists.getTracksByPlaylistId(playlist.id!).all();
return res.toList();
},
);
} }
return PlaybuttonCard( return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 10), margin: const EdgeInsets.symmetric(horizontal: 10),
title: playlist.name!, title: playlist.name!,
description: playlist.description, description: playlist.description,
imageUrl: TypeConversionUtils.image_X_UrlString( imageUrl: playlist.images.asUrlString(
playlist.images,
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),
isPlaying: isPlaylistPlaying, isPlaying: isPlaylistPlaying,
isLoading: isLoading:
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value, (isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null, isOwner: playlist.owner?.id == me.asData?.value.id &&
me.asData?.value.id != null,
onTap: () { onTap: () {
ServiceUtils.push( ServiceUtils.push(
context, context,
@ -90,11 +72,21 @@ class PlaylistCard extends HookConsumerWidget {
List<Track> fetchedTracks = await fetchAllTracks(); List<Track> fetchedTracks = await fetchAllTracks();
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty || !context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData(
tracks: fetchedTracks,
collectionId: playlist.id!,
),
);
} else {
await playlistNotifier.load(fetchedTracks, autoPlay: true); await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(playlist.id!); playlistNotifier.addCollection(playlist.id!);
tracks.value = fetchedTracks; }
} finally { } finally {
if (context.mounted) { if (context.mounted) {
updating.value = false; updating.value = false;
@ -112,10 +104,9 @@ class PlaylistCard extends HookConsumerWidget {
playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(playlist.id!); playlistNotifier.addCollection(playlist.id!);
tracks.value = fetchedTracks;
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( final snackbar = SnackBar(
content: Text("Added ${tracks.value?.length} tracks to queue"), content: Text("Added ${fetchedTracks.length} tracks to queue"),
action: SnackBarAction( action: SnackBarAction(
label: "Undo", label: "Undo",
onPressed: () { onPressed: () {

View File

@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_validator/form_validator.dart'; import 'package:form_validator/form_validator.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
@ -13,21 +14,19 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/mutations/playlist.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistCreateDialog extends HookConsumerWidget { class PlaylistCreateDialog extends HookConsumerWidget {
/// Track ids to add to the playlist /// Track ids to add to the playlist
final List<String> trackIds; final List<String> trackIds;
final String? playlistId; final String? playlistId;
PlaylistCreateDialog({ PlaylistCreateDialog({
Key? key, super.key,
this.trackIds = const [], this.trackIds = const [],
this.playlistId, this.playlistId,
}) : super(key: key); });
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
@ -37,13 +36,16 @@ class PlaylistCreateDialog extends HookConsumerWidget {
child: Scaffold( child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: HookBuilder(builder: (context) { body: HookBuilder(builder: (context) {
final userPlaylists = useQueries.playlist.ofMine(ref); final userPlaylists = ref.watch(favoritePlaylistsProvider);
final playlist = ref.watch(playlistProvider(playlistId ?? ""));
final playlistNotifier =
ref.watch(playlistProvider(playlistId ?? "").notifier);
final updatingPlaylist = useMemoized( final updatingPlaylist = useMemoized(
() => userPlaylists.pages () => userPlaylists.asData?.value.items
.expand((p) => p.items ?? <PlaylistSimple>[])
.firstWhereOrNull((playlist) => playlist.id == playlistId), .firstWhereOrNull((playlist) => playlist.id == playlistId),
[ [
userPlaylists.pages, userPlaylists.asData?.value.items,
playlistId, playlistId,
], ],
); );
@ -84,28 +86,10 @@ class PlaylistCreateDialog extends HookConsumerWidget {
} }
}, [scaffold, l10n, theme]); }, [scaffold, l10n, theme]);
final playlistCreateMutation = useMutations.playlist.create(
ref,
trackIds: trackIds,
onData: (value) {
Navigator.pop(context);
},
onError: onError,
);
final playlistUpdateMutation = useMutations.playlist.update(
ref,
playlistId: playlistId,
onData: (value) {
Navigator.pop(context);
},
onError: onError,
);
Future<void> onCreate() async { Future<void> onCreate() async {
if (!formKey.currentState!.validate()) return; if (!formKey.currentState!.validate()) return;
final PlaylistCRUDVariables payload = ( final PlaylistInput payload = (
playlistName: playlistName.text, playlistName: playlistName.text,
collaborative: collaborative.value, collaborative: collaborative.value,
public: public.value, public: public.value,
@ -118,9 +102,14 @@ class PlaylistCreateDialog extends HookConsumerWidget {
); );
if (isUpdatingPlaylist) { if (isUpdatingPlaylist) {
await playlistUpdateMutation.mutate(payload); await playlistNotifier.modify(payload, onError);
} else { } else {
await playlistCreateMutation.mutate(payload); await playlistNotifier.create(payload, onError);
}
if (context.mounted &&
!ref.read(playlistProvider(playlistId ?? "")).hasError) {
context.pop();
} }
} }
@ -138,7 +127,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
}, },
), ),
FilledButton( FilledButton(
onPressed: onCreate, onPressed: playlist.isLoading ? null : onCreate,
child: Text( child: Text(
isUpdatingPlaylist isUpdatingPlaylist
? context.l10n.update ? context.l10n.update
@ -174,8 +163,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
children: [ children: [
UniversalImage( UniversalImage(
path: field.value?.path ?? path: field.value?.path ??
TypeConversionUtils.image_X_UrlString( (updatingPlaylist?.images).asUrlString(
updatingPlaylist?.images,
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),
height: 200, height: 200,
@ -275,7 +263,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
} }
class PlaylistCreateDialogButton extends HookConsumerWidget { class PlaylistCreateDialogButton extends HookConsumerWidget {
const PlaylistCreateDialogButton({Key? key}) : super(key: key); const PlaylistCreateDialogButton({super.key});
showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
showDialog( showDialog(

View File

@ -14,6 +14,7 @@ import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/components/player/volume_slider.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -21,17 +22,17 @@ import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_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_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class BottomPlayer extends HookConsumerWidget { class BottomPlayer extends HookConsumerWidget {
BottomPlayer({Key? key}) : super(key: key); BottomPlayer({super.key});
final logger = getLogger(BottomPlayer); final logger = getLogger(BottomPlayer);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
@ -39,8 +40,7 @@ class BottomPlayer extends HookConsumerWidget {
String albumArt = useMemoized( String albumArt = useMemoized(
() => playlist.activeTrack?.album?.images?.isNotEmpty == true () => playlist.activeTrack?.album?.images?.isNotEmpty == true
? TypeConversionUtils.image_X_UrlString( ? (playlist.activeTrack?.album?.images).asUrlString(
playlist.activeTrack?.album?.images,
index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1,
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
) )
@ -74,7 +74,9 @@ class BottomPlayer extends HookConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded(child: PlayerTrackDetails(albumArt: albumArt)), Expanded(
child: PlayerTrackDetails(track: playlist.activeTrack),
),
// controls // controls
Flexible( Flexible(
flex: 3, flex: 3,
@ -122,10 +124,20 @@ class BottomPlayer extends HookConsumerWidget {
Container( Container(
height: 40, height: 40,
constraints: const BoxConstraints(maxWidth: 250), constraints: const BoxConstraints(maxWidth: 250),
child: const VolumeSlider(), padding: const EdgeInsets.only(right: 10),
child: Consumer(builder: (context, ref, _) {
final volume = ref.watch(volumeProvider);
return VolumeSlider(
fullWidth: true,
value: volume,
onChanged: (value) {
ref.read(volumeProvider.notifier).setVolume(value);
},
);
}),
) )
], ],
) ),
], ],
), ),
), ),

View File

@ -1,5 +1,6 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -8,19 +9,21 @@ import 'package:sidebarx/sidebarx.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/side_bar_tiles.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/connect/connect_device.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.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/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class Sidebar extends HookConsumerWidget { class Sidebar extends HookConsumerWidget {
final int? selectedIndex; final int? selectedIndex;
@ -31,8 +34,8 @@ class Sidebar extends HookConsumerWidget {
required this.selectedIndex, required this.selectedIndex,
required this.onSelectedIndexChanged, required this.onSelectedIndexChanged,
required this.child, required this.child,
Key? key, super.key,
}) : super(key: key); });
static Widget brandLogo() { static Widget brandLogo() {
return Container( return Container(
@ -195,7 +198,7 @@ class Sidebar extends HookConsumerWidget {
} }
class SidebarHeader extends HookWidget { class SidebarHeader extends HookWidget {
const SidebarHeader({Key? key}) : super(key: key); const SidebarHeader({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -234,23 +237,22 @@ class SidebarHeader extends HookWidget {
class SidebarFooter extends HookConsumerWidget { class SidebarFooter extends HookConsumerWidget {
const SidebarFooter({ const SidebarFooter({
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final me = useQueries.user.me(ref); final me = ref.watch(meProvider);
final data = me.data; final data = me.asData?.value;
final avatarImg = TypeConversionUtils.image_X_UrlString( final avatarImg = (data?.images).asUrlString(
data?.images,
index: (data?.images?.length ?? 1) - 1, index: (data?.images?.length ?? 1) - 1,
placeholder: ImagePlaceholder.artist, placeholder: ImagePlaceholder.artist,
); );
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
if (mediaQuery.mdAndDown) { if (mediaQuery.mdAndDown) {
return IconButton( return IconButton(
@ -262,7 +264,11 @@ class SidebarFooter extends HookConsumerWidget {
return Container( return Container(
padding: const EdgeInsets.only(left: 12), padding: const EdgeInsets.only(left: 12),
width: 250, width: 250,
child: Row( child: Column(
children: [
const ConnectDeviceButton.sidebar(),
const Gap(10),
Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -270,10 +276,16 @@ class SidebarFooter extends HookConsumerWidget {
const CircularProgressIndicator() const CircularProgressIndicator()
else if (data != null) else if (data != null)
Flexible( Flexible(
child: InkWell(
onTap: () {
ServiceUtils.push(context, "/profile");
},
borderRadius: BorderRadius.circular(30),
child: Row( child: Row(
children: [ children: [
CircleAvatar( CircleAvatar(
backgroundImage: UniversalImage.imageProvider(avatarImg), backgroundImage:
UniversalImage.imageProvider(avatarImg),
onBackgroundImageError: (exception, stackTrace) => onBackgroundImageError: (exception, stackTrace) =>
Assets.userPlaceholder.image( Assets.userPlaceholder.image(
height: 16, height: 16,
@ -294,6 +306,7 @@ class SidebarFooter extends HookConsumerWidget {
], ],
), ),
), ),
),
IconButton( IconButton(
icon: const Icon(SpotubeIcons.settings), icon: const Icon(SpotubeIcons.settings),
onPressed: () { onPressed: () {
@ -302,6 +315,8 @@ class SidebarFooter extends HookConsumerWidget {
), ),
], ],
), ),
],
),
); );
} }
} }

View File

@ -23,8 +23,8 @@ class SpotubeNavigationBar extends HookConsumerWidget {
const SpotubeNavigationBar({ const SpotubeNavigationBar({
required this.selectedIndex, required this.selectedIndex,
required this.onSelectedIndexChanged, required this.onSelectedIndexChanged,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {

View File

@ -8,9 +8,9 @@ import 'package:system_theme/system_theme.dart';
class SpotubeColor extends Color { class SpotubeColor extends Color {
final String name; final String name;
const SpotubeColor(int color, {required this.name}) : super(color); const SpotubeColor(super.color, {required this.name});
const SpotubeColor.from(int value, {required this.name}) : super(value); const SpotubeColor.from(super.value, {required this.name});
factory SpotubeColor.fromString(String string) { factory SpotubeColor.fromString(String string) {
final slices = string.split(":"); final slices = string.split(":");
@ -44,7 +44,7 @@ final Set<SpotubeColor> colorsMap = {
}; };
class ColorSchemePickerDialog extends HookConsumerWidget { class ColorSchemePickerDialog extends HookConsumerWidget {
const ColorSchemePickerDialog({Key? key}) : super(key: key); const ColorSchemePickerDialog({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -119,8 +119,8 @@ class ColorTile extends StatelessWidget {
this.onPressed, this.onPressed,
this.tooltip = "", this.tooltip = "",
this.isCompact = false, this.isCompact = false,
Key? key, super.key,
}) : super(key: key); });
factory ColorTile.compact({ factory ColorTile.compact({
required Color color, required Color color,

View File

@ -12,13 +12,13 @@ class Action extends StatelessWidget {
final bool isExpanded; final bool isExpanded;
final Color? backgroundColor; final Color? backgroundColor;
const Action({ const Action({
Key? key, super.key,
required this.icon, required this.icon,
required this.text, required this.text,
required this.onPressed, required this.onPressed,
this.isExpanded = true, this.isExpanded = true,
this.backgroundColor, this.backgroundColor,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
class AdaptiveSelectTile<T> extends HookWidget { class AdaptiveSelectTile<T> extends HookWidget {
@ -38,11 +39,22 @@ class AdaptiveSelectTile<T> extends HookWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final rawControl = DropdownButton<T>( final rawControl = DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: DropdownButton<T>(
items: options, items: options,
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,
menuMaxHeight: mediaQuery.size.height * 0.6, menuMaxHeight: mediaQuery.size.height * 0.6,
underline: const SizedBox.shrink(),
padding: const EdgeInsets.symmetric(horizontal: 10),
borderRadius: BorderRadius.circular(10),
icon: const Icon(SpotubeIcons.angleDown),
dropdownColor: theme.colorScheme.secondaryContainer,
),
); );
final controlPlaceholder = useMemoized( final controlPlaceholder = useMemoized(
() => options () => options

View File

@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
class AnimateGradient extends HookWidget { class AnimateGradient extends HookWidget {
const AnimateGradient({ const AnimateGradient({
Key? key, super.key,
required this.primaryColors, required this.primaryColors,
required this.secondaryColors, required this.secondaryColors,
this.child, this.child,
@ -17,8 +17,7 @@ class AnimateGradient extends HookWidget {
this.reverse = true, this.reverse = true,
}) : assert(primaryColors.length >= 2), }) : assert(primaryColors.length >= 2),
assert(primaryColors.length == secondaryColors.length), assert(primaryColors.length == secondaryColors.length),
_controller = controller, _controller = controller;
super(key: key);
/// [controller]: pass this to have a fine control over the [Animation] /// [controller]: pass this to have a fine control over the [Animation]
final AnimationController? _controller; final AnimationController? _controller;

View File

@ -79,7 +79,7 @@ class BorderedText extends StatelessWidget {
strutStyle: child.strutStyle, strutStyle: child.strutStyle,
textAlign: child.textAlign, textAlign: child.textAlign,
textDirection: child.textDirection, textDirection: child.textDirection,
textScaleFactor: child.textScaleFactor, textScaler: child.textScaler,
), ),
child, child,
], ],

View File

@ -11,12 +11,12 @@ class CompactSearch extends HookWidget {
final Color? iconColor; final Color? iconColor;
const CompactSearch({ const CompactSearch({
Key? key, super.key,
this.onChanged, this.onChanged,
this.placeholder = "Search...", this.placeholder = "Search...",
this.icon = SpotubeIcons.search, this.icon = SpotubeIcons.search,
this.iconColor, this.iconColor,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -5,7 +5,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
class ConfirmDownloadDialog extends StatelessWidget { class ConfirmDownloadDialog extends StatelessWidget {
const ConfirmDownloadDialog({Key? key}) : super(key: key); const ConfirmDownloadDialog({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -82,7 +82,7 @@ class ConfirmDownloadDialog extends StatelessWidget {
class BulletPoint extends StatelessWidget { class BulletPoint extends StatelessWidget {
final String text; final String text;
const BulletPoint(this.text, {Key? key}) : super(key: key); const BulletPoint(this.text, {super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -5,7 +5,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class PipedDownDialog extends HookConsumerWidget { class PipedDownDialog extends HookConsumerWidget {
const PipedDownDialog({Key? key}) : super(key: key); const PipedDownDialog({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {

View File

@ -1,4 +1,3 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -8,9 +7,8 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistAddTrackDialog extends HookConsumerWidget { class PlaylistAddTrackDialog extends HookConsumerWidget {
/// The id of the playlist this dialog was opened from /// The id of the playlist this dialog was opened from
@ -19,33 +17,40 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
const PlaylistAddTrackDialog({ const PlaylistAddTrackDialog({
required this.tracks, required this.tracks,
required this.openFromPlaylist, required this.openFromPlaylist,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context); final ThemeData(:textTheme) = Theme.of(context);
final spotify = ref.watch(spotifyProvider); final userPlaylists = ref.watch(favoritePlaylistsProvider);
final userPlaylists = useQueries.playlist.ofMineAll(ref); final favoritePlaylistsNotifier =
ref.watch(favoritePlaylistsProvider.notifier);
final me = useQueries.user.me(ref); final me = ref.watch(meProvider);
final filteredPlaylists = useMemoized( final filteredPlaylists = useMemoized(
() => () =>
userPlaylists.data userPlaylists.asData?.value.items
?.where( .where(
(playlist) => (playlist) =>
playlist.owner?.id != null && playlist.owner?.id != null &&
playlist.owner!.id == me.data?.id && playlist.owner!.id == me.asData?.value.id &&
playlist.id != openFromPlaylist, playlist.id != openFromPlaylist,
) )
.toList() ?? .toList() ??
[], [],
[userPlaylists.data, me.data?.id, openFromPlaylist], [userPlaylists.asData?.value, me.asData?.value.id, openFromPlaylist],
); );
final playlistsCheck = useState(<String, bool>{}); final playlistsCheck = useState(<String, bool>{});
final queryClient = useQueryClient();
useEffect(() {
if (userPlaylists.asData?.value != null) {
favoritePlaylistsNotifier.fetchAll();
}
return null;
}, [userPlaylists.asData?.value]);
Future<void> onAdd() async { Future<void> onAdd() async {
final selectedPlaylists = playlistsCheck.value.entries final selectedPlaylists = playlistsCheck.value.entries
@ -54,21 +59,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
await Future.wait( await Future.wait(
selectedPlaylists.map( selectedPlaylists.map(
(playlistId) => spotify.playlists.addTracks( (playlistId) => favoritePlaylistsNotifier.addTracks(
tracks playlistId,
.map( tracks.map((e) => e.id!).toList(),
(track) => track.uri!, ),
)
.toList(),
playlistId),
), ),
).then((_) => Navigator.pop(context, true)); ).then((_) => Navigator.pop(context, true));
await queryClient.refreshQueries(
selectedPlaylists
.map((playlistId) => "playlist-tracks/$playlistId")
.toList(),
);
} }
return AlertDialog( return AlertDialog(
@ -109,8 +105,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
return CheckboxListTile( return CheckboxListTile(
secondary: CircleAvatar( secondary: CircleAvatar(
backgroundImage: UniversalImage.imageProvider( backgroundImage: UniversalImage.imageProvider(
TypeConversionUtils.image_X_UrlString( playlist.images.asUrlString(
playlist.images,
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),
), ),

View File

@ -8,8 +8,7 @@ final replaceDownloadedFileState = StateProvider<bool?>((ref) => null);
class ReplaceDownloadedDialog extends ConsumerWidget { class ReplaceDownloadedDialog extends ConsumerWidget {
final Track track; final Track track;
const ReplaceDownloadedDialog({required this.track, Key? key}) const ReplaceDownloadedDialog({required this.track, super.key});
: super(key: key);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {

View File

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/connect/clients.dart';
class SelectDeviceDialog extends HookConsumerWidget {
const SelectDeviceDialog({super.key});
@override
Widget build(BuildContext context, ref) {
final isRemoteService = useState(false);
final connectClients = ref.watch(connectClientsProvider);
final remoteService = connectClients.asData!.value.resolvedService!;
return AlertDialog(
title: const Text("Choose the device:"),
insetPadding: const EdgeInsets.all(16),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"There are multiple device connected.\n"
"Choose the device you want this action to take place",
),
RadioListTile.adaptive(
title: Text(remoteService.name),
value: true,
groupValue: isRemoteService.value,
onChanged: (value) {
isRemoteService.value = value!;
},
),
RadioListTile.adaptive(
title: const Text("This Device"),
value: false,
groupValue: isRemoteService.value,
onChanged: (value) {
isRemoteService.value = !value!;
},
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(isRemoteService.value);
},
child: Text(context.l10n.select),
),
],
);
}
}
Future<bool> showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
final connectClients = ref.read(connectClientsProvider);
if (connectClients.asData?.value.resolvedService == null) {
return false;
}
final isRemote = await showDialog<bool>(
context: context,
builder: (context) => const SelectDeviceDialog(),
);
return isRemote ?? false;
}

View File

@ -2,20 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/links/hyper_link.dart';
import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
class TrackDetailsDialog extends HookWidget { class TrackDetailsDialog extends HookWidget {
final Track track; final Track track;
const TrackDetailsDialog({ const TrackDetailsDialog({
Key? key, super.key,
required this.track, required this.track,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -24,8 +24,8 @@ class TrackDetailsDialog extends HookWidget {
final detailsMap = { final detailsMap = {
context.l10n.title: track.name!, context.l10n.title: track.name!,
context.l10n.artist: TypeConversionUtils.artists_X_ClickableArtists( context.l10n.artist: ArtistLink(
track.artists ?? <Artist>[], artists: track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start, mainAxisAlignment: WrapAlignment.start,
textStyle: const TextStyle(color: Colors.blue), textStyle: const TextStyle(color: Colors.blue),
), ),

View File

@ -10,12 +10,12 @@ class ExpandableSearchField extends StatelessWidget {
final FocusNode searchFocus; final FocusNode searchFocus;
const ExpandableSearchField({ const ExpandableSearchField({
Key? key, super.key,
required this.isFiltering, required this.isFiltering,
required this.onChangeFiltering, required this.onChangeFiltering,
required this.searchController, required this.searchController,
required this.searchFocus, required this.searchFocus,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -60,12 +60,12 @@ class ExpandableSearchButton extends StatelessWidget {
final ValueChanged<bool>? onPressed; final ValueChanged<bool>? onPressed;
const ExpandableSearchButton({ const ExpandableSearchButton({
Key? key, super.key,
required this.isFiltering, required this.isFiltering,
required this.searchFocus, required this.searchFocus,
this.icon = const Icon(SpotubeIcons.filter), this.icon = const Icon(SpotubeIcons.filter),
this.onPressed, this.onPressed,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -8,13 +8,13 @@ import 'package:spotube/utils/service_utils.dart';
class AnonymousFallback extends ConsumerWidget { class AnonymousFallback extends ConsumerWidget {
final Widget? child; final Widget? child;
const AnonymousFallback({ const AnonymousFallback({
Key? key, super.key,
this.child, this.child,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final isLoggedIn = ref.watch(AuthenticationNotifier.provider) != null; final isLoggedIn = ref.watch(authenticationProvider) != null;
if (isLoggedIn && child != null) return child!; if (isLoggedIn && child != null) return child!;
return Center( return Center(

View File

@ -3,7 +3,7 @@ import 'package:spotube/collections/assets.gen.dart';
class NotFound extends StatelessWidget { class NotFound extends StatelessWidget {
final bool vertical; final bool vertical;
const NotFound({Key? key, this.vertical = false}) : super(key: key); const NotFound({super.key, this.vertical = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -1,5 +1,3 @@
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -8,8 +6,7 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/services/mutations/mutations.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/queries/queries.dart';
class HeartButton extends HookConsumerWidget { class HeartButton extends HookConsumerWidget {
final bool isLiked; final bool isLiked;
@ -23,12 +20,12 @@ class HeartButton extends HookConsumerWidget {
this.color, this.color,
this.tooltip, this.tooltip,
this.icon, this.icon,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
if (auth == null) return const SizedBox.shrink(); if (auth == null) return const SizedBox.shrink();
@ -60,90 +57,50 @@ class HeartButton extends HookConsumerWidget {
typedef UseTrackToggleLike = ({ typedef UseTrackToggleLike = ({
bool isLiked, bool isLiked,
Mutation<bool, dynamic, bool> toggleTrackLike, Future<void> Function(Track track) toggleTrackLike,
Query<User?, dynamic> me,
}); });
UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
final me = useQueries.user.me(ref); final savedTracks = ref.watch(likedTracksProvider);
final savedTracksNotifier = ref.watch(likedTracksProvider.notifier);
final savedTracks = useQueries.playlist.likedTracksQuery(ref);
final isLiked = useMemoized( final isLiked = useMemoized(
() => savedTracks.data?.any((element) => element.id == track.id) ?? false, () =>
[savedTracks.data, track.id], savedTracks.asData?.value.any((element) => element.id == track.id) ??
false,
[savedTracks.asData?.value, track.id],
); );
final mounted = useIsMounted();
final scrobblerNotifier = ref.read(scrobblerProvider.notifier); final scrobblerNotifier = ref.read(scrobblerProvider.notifier);
final toggleTrackLike = useMutations.track.toggleFavorite( return (
ref, isLiked: isLiked,
track.id!, toggleTrackLike: (track) async {
onMutate: (isLiked) { await savedTracksNotifier.toggleFavorite(track);
if (isLiked) {
savedTracks.setData( if (!isLiked) {
savedTracks.data
?.where((element) => element.id != track.id)
.toList() ??
[],
);
} else {
savedTracks.setData(
[
...?savedTracks.data,
track,
],
);
}
return isLiked;
},
onData: (isLiked, recoveryData) async {
await savedTracks.refresh();
if (isLiked) {
await scrobblerNotifier.love(track); await scrobblerNotifier.love(track);
} else { } else {
await scrobblerNotifier.unlove(track); await scrobblerNotifier.unlove(track);
} }
}, },
onError: (payload, isLiked) {
if (!mounted()) return;
if (isLiked != true) {
savedTracks.setData(
savedTracks.data
?.where((element) => element.id != track.id)
.toList() ??
[],
); );
} else {
savedTracks.setData(
[
...?savedTracks.data,
track,
],
);
}
},
);
return (isLiked: isLiked, toggleTrackLike: toggleTrackLike, me: me);
} }
class TrackHeartButton extends HookConsumerWidget { class TrackHeartButton extends HookConsumerWidget {
final Track track; final Track track;
const TrackHeartButton({ const TrackHeartButton({
Key? key, super.key,
required this.track, required this.track,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final savedTracks = useQueries.playlist.likedTracksQuery(ref); final savedTracks = ref.watch(likedTracksProvider);
final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); final me = ref.watch(meProvider);
final (:isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
if (me.isLoading || !me.hasData) { if (me.isLoading) {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
@ -152,104 +109,9 @@ class TrackHeartButton extends HookConsumerWidget {
? context.l10n.remove_from_favorites ? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite, : context.l10n.save_as_favorite,
isLiked: isLiked, isLiked: isLiked,
onPressed: savedTracks.hasData onPressed: savedTracks.asData?.value != null
? () { ? () {
toggleTrackLike.mutate(isLiked); toggleTrackLike(track);
}
: null,
);
}
}
class PlaylistHeartButton extends HookConsumerWidget {
final PlaylistSimple playlist;
final IconData? icon;
final ValueChanged<bool>? onData;
const PlaylistHeartButton({
required this.playlist,
Key? key,
this.icon,
this.onData,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final me = useQueries.user.me(ref);
final isLikedQuery = useQueries.playlist.doesUserFollow(
ref,
playlist.id!,
me.data?.id ?? '',
);
final togglePlaylistLike = useMutations.playlist.toggleFavorite(
ref,
playlist.id!,
refreshQueries: [
isLikedQuery.key,
],
onData: onData,
);
if (me.isLoading || !me.hasData) {
return const CircularProgressIndicator();
}
return HeartButton(
isLiked: isLikedQuery.data ?? false,
tooltip: isLikedQuery.data ?? false
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
color: Colors.white,
icon: icon,
onPressed: isLikedQuery.hasData
? () {
togglePlaylistLike.mutate(isLikedQuery.data!);
}
: null,
);
}
}
class AlbumHeartButton extends HookConsumerWidget {
final AlbumSimple album;
const AlbumHeartButton({
required this.album,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final client = useQueryClient();
final me = useQueries.user.me(ref);
final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!);
final isLiked = albumIsSaved.data ?? false;
final toggleAlbumLike = useMutations.album.toggleFavorite(
ref,
album.id!,
refreshQueries: [albumIsSaved.key],
onData: (_, __) async {
await client.refreshInfiniteQueryAllPages("current-user-albums");
},
);
if (me.isLoading || !me.hasData) {
return const CircularProgressIndicator();
}
return HeartButton(
isLiked: isLiked,
tooltip: isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
color: Colors.white,
onPressed: albumIsSaved.hasData
? () {
toggleAlbumLike.mutate(isLiked);
} }
: null, : null,
); );

View File

@ -17,20 +17,22 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
final VoidCallback onFetchMore; final VoidCallback onFetchMore;
final bool isLoadingNextPage; final bool isLoadingNextPage;
final bool hasNextPage; final bool hasNextPage;
final Widget? titleTrailing;
const HorizontalPlaybuttonCardView({ HorizontalPlaybuttonCardView({
required this.title, required this.title,
required this.items, required this.items,
required this.hasNextPage, required this.hasNextPage,
required this.onFetchMore, required this.onFetchMore,
required this.isLoadingNextPage, required this.isLoadingNextPage,
Key? key, this.titleTrailing,
super.key,
}) : assert( }) : assert(
items is List<PlaylistSimple> || items.every(
items is List<Album> || (item) =>
items is List<Artist>, item is PlaylistSimple || item is Artist || item is AlbumSimple,
), ),
super(key: key); );
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -48,11 +50,17 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
DefaultTextStyle( DefaultTextStyle(
style: textTheme.titleMedium!, style: textTheme.titleMedium!,
child: title, child: title,
), ),
if (titleTrailing != null) titleTrailing!,
],
),
SizedBox( SizedBox(
height: height, height: height,
child: NotificationListener( child: NotificationListener(
@ -85,11 +93,11 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
return switch (item.runtimeType) { return switch (item) {
PlaylistSimple => PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple), PlaylistCard(item as PlaylistSimple),
Album => AlbumCard(item as Album), AlbumSimple() => AlbumCard(item as Album),
Artist => Padding( Artist() => Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12.0), horizontal: 12.0),
child: ArtistCard(item as Artist), child: ArtistCard(item as Artist),

View File

@ -7,8 +7,8 @@ class HoverBuilder extends HookWidget {
const HoverBuilder({ const HoverBuilder({
required this.builder, required this.builder,
this.permanentState, this.permanentState,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -20,8 +20,8 @@ class UniversalImage extends HookWidget {
this.placeholder, this.placeholder,
this.fit, this.fit,
this.scale = 1, this.scale = 1,
Key? key, super.key,
}) : super(key: key); });
static ImageProvider imageProvider( static ImageProvider imageProvider(
String path, { String path, {

View File

@ -11,13 +11,13 @@ class AnchorButton<T> extends HookWidget {
const AnchorButton( const AnchorButton(
this.text, { this.text, {
Key? key, super.key,
this.onTap, this.onTap,
this.textAlign, this.textAlign,
this.overflow, this.overflow,
this.maxLines, this.maxLines,
this.style = const TextStyle(), this.style = const TextStyle(),
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -0,0 +1,57 @@
import 'package:flutter/widgets.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/links/anchor_button.dart';
import 'package:spotube/utils/service_utils.dart';
class ArtistLink extends StatelessWidget {
final List<ArtistSimple> artists;
final WrapCrossAlignment crossAxisAlignment;
final WrapAlignment mainAxisAlignment;
final TextStyle textStyle;
final void Function(String route)? onRouteChange;
const ArtistLink({
super.key,
required this.artists,
this.crossAxisAlignment = WrapCrossAlignment.center,
this.mainAxisAlignment = WrapAlignment.center,
this.textStyle = const TextStyle(),
this.onRouteChange,
});
@override
Widget build(BuildContext context) {
return Wrap(
crossAxisAlignment: crossAxisAlignment,
alignment: mainAxisAlignment,
children: artists
.asMap()
.entries
.map(
(artist) => Builder(builder: (context) {
if (artist.value.name == null) {
return Text("Spotify", style: textStyle);
}
return AnchorButton(
(artist.key != artists.length - 1)
? "${artist.value.name}, "
: artist.value.name!,
onTap: () {
if (onRouteChange != null) {
onRouteChange?.call("/artist/${artist.value.id}");
} else {
ServiceUtils.push(
context,
"/artist/${artist.value.id}",
);
}
},
overflow: TextOverflow.ellipsis,
style: textStyle,
);
}),
)
.toList(),
);
}
}

View File

@ -13,12 +13,12 @@ class Hyperlink extends StatelessWidget {
const Hyperlink( const Hyperlink(
this.text, this.text,
this.url, { this.url, {
Key? key, super.key,
this.textAlign, this.textAlign,
this.overflow, this.overflow,
this.style = const TextStyle(), this.style = const TextStyle(),
this.maxLines, this.maxLines,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -15,14 +15,14 @@ class LinkText<T> extends StatelessWidget {
const LinkText( const LinkText(
this.text, this.text,
this.route, { this.route, {
Key? key, super.key,
this.textAlign, this.textAlign,
this.extra, this.extra,
this.overflow, this.overflow,
this.style = const TextStyle(), this.style = const TextStyle(),
this.maxLines, this.maxLines,
this.push = false, this.push = false,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -26,8 +26,10 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
final double? titleWidth; final double? titleWidth;
final Widget? title; final Widget? title;
final bool _sliver;
const PageWindowTitleBar({ const PageWindowTitleBar({
Key? key, super.key,
this.actions, this.actions,
this.title, this.title,
this.toolbarOpacity = 1, this.toolbarOpacity = 1,
@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
this.titleTextStyle, this.titleTextStyle,
this.titleWidth, this.titleWidth,
this.toolbarTextStyle, this.toolbarTextStyle,
}) : super(key: key); }) : _sliver = false,
pinned = false,
floating = false,
snap = false,
stretch = false;
final bool pinned;
final bool floating;
final bool snap;
final bool stretch;
const PageWindowTitleBar.sliver({
super.key,
this.actions,
this.title,
this.backgroundColor,
this.actionsIconTheme,
this.automaticallyImplyLeading = false,
this.centerTitle,
this.foregroundColor,
this.leading,
this.leadingWidth,
this.titleSpacing,
this.titleTextStyle,
this.titleWidth,
this.toolbarTextStyle,
this.pinned = false,
this.floating = false,
this.snap = false,
this.stretch = false,
}) : _sliver = true,
toolbarOpacity = 1;
@override @override
Size get preferredSize => const Size.fromHeight(kToolbarHeight); Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
if (widget._sliver) {
return SliverLayoutBuilder(
builder: (context, constraints) {
final hasFullscreen =
mediaQuery.size.width == constraints.crossAxisExtent;
final hasLeadingOrCanPop =
widget.leading != null || Navigator.canPop(context);
return SliverPadding(
padding: EdgeInsets.only(
left: DesktopTools.platform.isMacOS &&
hasFullscreen &&
hasLeadingOrCanPop
? 65
: 0,
),
sliver: SliverAppBar(
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
actions: [
...?widget.actions,
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
],
backgroundColor: widget.backgroundColor,
foregroundColor: widget.foregroundColor,
actionsIconTheme: widget.actionsIconTheme,
centerTitle: widget.centerTitle,
titleSpacing: widget.titleSpacing,
leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
title: widget.title,
pinned: widget.pinned,
floating: widget.floating,
snap: widget.snap,
stretch: widget.stretch,
),
);
},
);
}
return LayoutBuilder(builder: (context, constrains) { return LayoutBuilder(builder: (context, constrains) {
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
final hasLeadingOrCanPop = final hasLeadingOrCanPop =
@ -107,9 +182,9 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
class WindowTitleBarButtons extends HookConsumerWidget { class WindowTitleBarButtons extends HookConsumerWidget {
final Color? foregroundColor; final Color? foregroundColor;
const WindowTitleBarButtons({ const WindowTitleBarButtons({
Key? key, super.key,
this.foregroundColor, this.foregroundColor,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -277,14 +352,13 @@ class WindowButton extends StatelessWidget {
final VoidCallback? onPressed; final VoidCallback? onPressed;
WindowButton( WindowButton(
{Key? key, {super.key,
WindowButtonColors? colors, WindowButtonColors? colors,
this.builder, this.builder,
@required this.iconBuilder, @required this.iconBuilder,
this.padding, this.padding,
this.onPressed, this.onPressed,
this.animate = false}) this.animate = false}) {
: super(key: key) {
this.colors = colors ?? _defaultButtonColors; this.colors = colors ?? _defaultButtonColors;
} }
@ -350,49 +424,30 @@ class WindowButton extends StatelessWidget {
class MinimizeWindowButton extends WindowButton { class MinimizeWindowButton extends WindowButton {
MinimizeWindowButton( MinimizeWindowButton(
{Key? key, {super.key, super.colors, super.onPressed, bool? animate})
WindowButtonColors? colors,
VoidCallback? onPressed,
bool? animate})
: super( : super(
key: key,
colors: colors,
animate: animate ?? false, animate: animate ?? false,
iconBuilder: (buttonContext) => iconBuilder: (buttonContext) =>
MinimizeIcon(color: buttonContext.iconColor), MinimizeIcon(color: buttonContext.iconColor),
onPressed: onPressed,
); );
} }
class MaximizeWindowButton extends WindowButton { class MaximizeWindowButton extends WindowButton {
MaximizeWindowButton( MaximizeWindowButton(
{Key? key, {super.key, super.colors, super.onPressed, bool? animate})
WindowButtonColors? colors,
VoidCallback? onPressed,
bool? animate})
: super( : super(
key: key,
colors: colors,
animate: animate ?? false, animate: animate ?? false,
iconBuilder: (buttonContext) => iconBuilder: (buttonContext) =>
MaximizeIcon(color: buttonContext.iconColor), MaximizeIcon(color: buttonContext.iconColor),
onPressed: onPressed,
); );
} }
class RestoreWindowButton extends WindowButton { class RestoreWindowButton extends WindowButton {
RestoreWindowButton( RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
{Key? key,
WindowButtonColors? colors,
VoidCallback? onPressed,
bool? animate})
: super( : super(
key: key,
colors: colors,
animate: animate ?? false, animate: animate ?? false,
iconBuilder: (buttonContext) => iconBuilder: (buttonContext) =>
RestoreIcon(color: buttonContext.iconColor), RestoreIcon(color: buttonContext.iconColor),
onPressed: onPressed,
); );
} }
@ -404,17 +459,12 @@ final _defaultCloseButtonColors = WindowButtonColors(
class CloseWindowButton extends WindowButton { class CloseWindowButton extends WindowButton {
CloseWindowButton( CloseWindowButton(
{Key? key, {super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
WindowButtonColors? colors,
VoidCallback? onPressed,
bool? animate})
: super( : super(
key: key,
colors: colors ?? _defaultCloseButtonColors, colors: colors ?? _defaultCloseButtonColors,
animate: animate ?? false, animate: animate ?? false,
iconBuilder: (buttonContext) => iconBuilder: (buttonContext) =>
CloseIcon(color: buttonContext.iconColor), CloseIcon(color: buttonContext.iconColor),
onPressed: onPressed,
); );
} }
@ -423,7 +473,7 @@ class CloseWindowButton extends WindowButton {
/// Close /// Close
class CloseIcon extends StatelessWidget { class CloseIcon extends StatelessWidget {
final Color color; final Color color;
const CloseIcon({Key? key, required this.color}) : super(key: key); const CloseIcon({super.key, required this.color});
@override @override
Widget build(BuildContext context) => Align( Widget build(BuildContext context) => Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
@ -444,13 +494,13 @@ class CloseIcon extends StatelessWidget {
/// Maximize /// Maximize
class MaximizeIcon extends StatelessWidget { class MaximizeIcon extends StatelessWidget {
final Color color; final Color color;
const MaximizeIcon({Key? key, required this.color}) : super(key: key); const MaximizeIcon({super.key, required this.color});
@override @override
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
} }
class _MaximizePainter extends _IconPainter { class _MaximizePainter extends _IconPainter {
_MaximizePainter(Color color) : super(color); _MaximizePainter(super.color);
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
Paint p = getPaint(color); Paint p = getPaint(color);
@ -462,15 +512,15 @@ class _MaximizePainter extends _IconPainter {
class RestoreIcon extends StatelessWidget { class RestoreIcon extends StatelessWidget {
final Color color; final Color color;
const RestoreIcon({ const RestoreIcon({
Key? key, super.key,
required this.color, required this.color,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
} }
class _RestorePainter extends _IconPainter { class _RestorePainter extends _IconPainter {
_RestorePainter(Color color) : super(color); _RestorePainter(super.color);
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
Paint p = getPaint(color); Paint p = getPaint(color);
@ -487,13 +537,13 @@ class _RestorePainter extends _IconPainter {
/// Minimize /// Minimize
class MinimizeIcon extends StatelessWidget { class MinimizeIcon extends StatelessWidget {
final Color color; final Color color;
const MinimizeIcon({Key? key, required this.color}) : super(key: key); const MinimizeIcon({super.key, required this.color});
@override @override
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
} }
class _MinimizePainter extends _IconPainter { class _MinimizePainter extends _IconPainter {
_MinimizePainter(Color color) : super(color); _MinimizePainter(super.color);
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
Paint p = getPaint(color); Paint p = getPaint(color);
@ -512,7 +562,7 @@ abstract class _IconPainter extends CustomPainter {
} }
class _AlignedPaint extends StatelessWidget { class _AlignedPaint extends StatelessWidget {
const _AlignedPaint(this.painter, {Key? key}) : super(key: key); const _AlignedPaint(this.painter);
final CustomPainter painter; final CustomPainter painter;
@override @override
@ -547,9 +597,9 @@ T? _ambiguate<T>(T? value) => value;
class MouseStateBuilder extends StatefulWidget { class MouseStateBuilder extends StatefulWidget {
final MouseStateBuilderCB builder; final MouseStateBuilderCB builder;
final VoidCallback? onPressed; final VoidCallback? onPressed;
const MouseStateBuilder({Key? key, required this.builder, this.onPressed}) const MouseStateBuilder({super.key, required this.builder, this.onPressed});
: super(key: key);
@override @override
// ignore: library_private_types_in_public_api
_MouseStateBuilderState createState() => _MouseStateBuilderState(); _MouseStateBuilderState createState() => _MouseStateBuilderState();
} }

View File

@ -1,4 +1,4 @@
part of panels; part of './sliding_up_panel.dart';
class PanelController extends ChangeNotifier { class PanelController extends ChangeNotifier {
SlidingUpPanelState? _panelState; SlidingUpPanelState? _panelState;

View File

@ -1,4 +1,4 @@
part of panels; part of "./sliding_up_panel.dart";
/// if you want to prevent the panel from being dragged using the widget, /// if you want to prevent the panel from being dragged using the widget,
/// wrap the widget with this /// wrap the widget with this
@ -47,8 +47,7 @@ class ForceDraggableWidgetRenderBox extends RenderPointerListener {
/// To make [ForceDraggableWidget] work in [Scrollable] widgets /// To make [ForceDraggableWidget] work in [Scrollable] widgets
class PanelScrollPhysics extends ScrollPhysics { class PanelScrollPhysics extends ScrollPhysics {
final PanelController controller; final PanelController controller;
const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) const PanelScrollPhysics({required this.controller, super.parent});
: super(parent: parent);
@override @override
PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { PanelScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PanelScrollPhysics( return PanelScrollPhysics(

View File

@ -146,7 +146,7 @@ class SlidingUpPanel extends StatefulWidget {
final BoxDecoration? panelDecoration; final BoxDecoration? panelDecoration;
const SlidingUpPanel( const SlidingUpPanel(
{Key? key, {super.key,
this.body, this.body,
this.collapsed, this.collapsed,
this.minHeight = 100.0, this.minHeight = 100.0,
@ -176,8 +176,7 @@ class SlidingUpPanel extends StatefulWidget {
this.panelBuilder}) this.panelBuilder})
: assert(panelBuilder != null), : assert(panelBuilder != null),
assert(0 <= backdropOpacity && backdropOpacity <= 1.0), assert(0 <= backdropOpacity && backdropOpacity <= 1.0),
assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0);
super(key: key);
@override @override
SlidingUpPanelState createState() => SlidingUpPanelState(); SlidingUpPanelState createState() => SlidingUpPanelState();

View File

@ -43,8 +43,8 @@ class PlaybuttonCard extends HookWidget {
this.onAddToQueuePressed, this.onAddToQueuePressed,
this.onTap, this.onTap,
this.isOwner = false, this.isOwner = false,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -5,7 +5,7 @@ import 'package:gap/gap.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
class ShimmerLyrics extends HookWidget { class ShimmerLyrics extends HookWidget {
const ShimmerLyrics({Key? key}) : super(key: key); const ShimmerLyrics({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -11,8 +11,8 @@ class SortTracksDropdown extends StatelessWidget {
const SortTracksDropdown({ const SortTracksDropdown({
this.onChanged, this.onChanged,
this.value, this.value,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -5,7 +5,7 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
final List<Widget> tabs; final List<Widget> tabs;
const ThemedButtonsTabBar({Key? key, required this.tabs}) : super(key: key); const ThemedButtonsTabBar({super.key, required this.tabs});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -1,6 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart' hide Page; import 'package:flutter/material.dart' hide Page;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@ -16,17 +15,18 @@ import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/search.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue { enum TrackOptionValue {
@ -53,13 +53,13 @@ class TrackOptions extends HookConsumerWidget {
final ObjectRef<ValueChanged<RelativeRect>?>? showMenuCbRef; final ObjectRef<ValueChanged<RelativeRect>?>? showMenuCbRef;
final Widget? icon; final Widget? icon;
const TrackOptions({ const TrackOptions({
Key? key, super.key,
required this.track, required this.track,
this.showMenuCbRef, this.showMenuCbRef,
this.userPlaylist = false, this.userPlaylist = false,
this.playlistId, this.playlistId,
this.icon, this.icon,
}) : super(key: key); });
void actionShare(BuildContext context, Track track) { void actionShare(BuildContext context, Track track) {
final data = "https://open.spotify.com/track/${track.id}"; final data = "https://open.spotify.com/track/${track.id}";
@ -95,25 +95,14 @@ class TrackOptions extends HookConsumerWidget {
WidgetRef ref, WidgetRef ref,
Track track, Track track,
) async { ) async {
final playback = ref.read(ProxyPlaylistNotifier.notifier); final playback = ref.read(proxyPlaylistProvider.notifier);
final playlist = ref.read(ProxyPlaylistNotifier.provider); final playlist = ref.read(proxyPlaylistProvider);
final spotify = ref.read(spotifyProvider); final spotify = ref.read(spotifyProvider);
final query = "${track.name} Radio"; final query = "${track.name} Radio";
final pages = await QueryClient.of(context) final pages =
.fetchInfiniteQueryJob<List<Page>, dynamic, int, SearchParams>( await spotify.search.get(query, types: [SearchType.playlist]).first();
job: SearchQueries.queryJob(query),
args: (
spotify: spotify,
searchType: SearchType.playlist,
query: query,
),
) ??
[];
final radios = pages final radios = pages.map((e) => e.items).toList().cast<PlaylistSimple>();
.expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
.toList()
.cast<PlaylistSimple>();
final artists = track.artists!.map((e) => e.name); final artists = track.artists!.map((e) => e.name);
@ -170,12 +159,13 @@ class TrackOptions extends HookConsumerWidget {
final router = GoRouter.of(context); final router = GoRouter.of(context);
final ThemeData(:colorScheme) = Theme.of(context); final ThemeData(:colorScheme) = Theme.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier); final playback = ref.watch(proxyPlaylistProvider.notifier);
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
ref.watch(downloadManagerProvider); ref.watch(downloadManagerProvider);
final downloadManager = ref.watch(downloadManagerProvider.notifier); final downloadManager = ref.watch(downloadManagerProvider.notifier);
final blacklist = ref.watch(BlackListNotifier.provider); final blacklist = ref.watch(blacklistProvider);
final me = ref.watch(meProvider);
final favorites = useTrackToggleLike(track, ref); final favorites = useTrackToggleLike(track, ref);
@ -190,10 +180,8 @@ class TrackOptions extends HookConsumerWidget {
); );
final removingTrack = useState<String?>(null); final removingTrack = useState<String?>(null);
final removeTrack = useMutations.playlist.removeTrackOf( final favoritePlaylistsNotifier =
ref, ref.watch(favoritePlaylistsProvider.notifier);
playlistId ?? "",
);
final isInQueue = useMemoized(() { final isInQueue = useMemoized(() {
if (playlist.activeTrack == null) return false; if (playlist.activeTrack == null) return false;
@ -220,7 +208,7 @@ class TrackOptions extends HookConsumerWidget {
break; break;
case TrackOptionValue.delete: case TrackOptionValue.delete:
await File((track as LocalTrack).path).delete(); await File((track as LocalTrack).path).delete();
ref.refresh(localTracksProvider); ref.invalidate(localTracksProvider);
break; break;
case TrackOptionValue.addToQueue: case TrackOptionValue.addToQueue:
await playback.addTrack(track); await playback.addTrack(track);
@ -257,22 +245,23 @@ class TrackOptions extends HookConsumerWidget {
); );
break; break;
case TrackOptionValue.favorite: case TrackOptionValue.favorite:
favorites.toggleTrackLike.mutate(favorites.isLiked); favorites.toggleTrackLike(track);
break; break;
case TrackOptionValue.addToPlaylist: case TrackOptionValue.addToPlaylist:
actionAddToPlaylist(context, track); actionAddToPlaylist(context, track);
break; break;
case TrackOptionValue.removeFromPlaylist: case TrackOptionValue.removeFromPlaylist:
removingTrack.value = track.uri; removingTrack.value = track.uri;
removeTrack.mutate(track.uri!); favoritePlaylistsNotifier
.removeTracks(playlistId ?? "", [track.id!]);
break; break;
case TrackOptionValue.blacklist: case TrackOptionValue.blacklist:
if (isBlackListed) { if (isBlackListed) {
ref.read(BlackListNotifier.provider.notifier).remove( ref.read(blacklistProvider.notifier).remove(
BlacklistedElement.track(track.id!, track.name!), BlacklistedElement.track(track.id!, track.name!),
); );
} else { } else {
ref.read(BlackListNotifier.provider.notifier).add( ref.read(blacklistProvider.notifier).add(
BlacklistedElement.track(track.id!, track.name!), BlacklistedElement.track(track.id!, track.name!),
); );
} }
@ -307,8 +296,8 @@ class TrackOptions extends HookConsumerWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
child: UniversalImage( child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(track.album!.images, path: track.album!.images
placeholder: ImagePlaceholder.albumArt), .asUrlString(placeholder: ImagePlaceholder.albumArt),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
), ),
@ -321,14 +310,12 @@ class TrackOptions extends HookConsumerWidget {
), ),
subtitle: Align( subtitle: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TypeConversionUtils.artists_X_ClickableArtists( child: ArtistLink(artists: track.artists!),
track.artists!,
),
), ),
), ),
], ],
children: switch (track.runtimeType) { children: switch (track.runtimeType) {
LocalTrack => [ LocalTrack() => [
PopSheetEntry( PopSheetEntry(
value: TrackOptionValue.delete, value: TrackOptionValue.delete,
leading: const Icon(SpotubeIcons.trash), leading: const Icon(SpotubeIcons.trash),
@ -361,7 +348,7 @@ class TrackOptions extends HookConsumerWidget {
leading: const Icon(SpotubeIcons.queueRemove), leading: const Icon(SpotubeIcons.queueRemove),
title: Text(context.l10n.remove_from_queue), title: Text(context.l10n.remove_from_queue),
), ),
if (favorites.me.hasData) if (me.asData?.value != null)
PopSheetEntry( PopSheetEntry(
value: TrackOptionValue.favorite, value: TrackOptionValue.favorite,
leading: favorites.isLiked leading: favorites.isLiked
@ -391,10 +378,7 @@ class TrackOptions extends HookConsumerWidget {
if (userPlaylist && auth != null) if (userPlaylist && auth != null)
PopSheetEntry( PopSheetEntry(
value: TrackOptionValue.removeFromPlaylist, value: TrackOptionValue.removeFromPlaylist,
leading: (removeTrack.isMutating || !removeTrack.hasData) && leading: const Icon(SpotubeIcons.removeFilled),
removingTrack.value == track.uri
? const CircularProgressIndicator()
: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist), title: Text(context.l10n.remove_from_playlist),
), ),
PopSheetEntry( PopSheetEntry(

View File

@ -9,14 +9,16 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/hover_builder.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class TrackTile extends HookConsumerWidget { class TrackTile extends HookConsumerWidget {
/// [index] will not be shown if null /// [index] will not be shown if null
@ -28,28 +30,29 @@ class TrackTile extends HookConsumerWidget {
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
final bool userPlaylist; final bool userPlaylist;
final String? playlistId; final String? playlistId;
final ProxyPlaylist playlist;
final List<Widget>? leadingActions; final List<Widget>? leadingActions;
const TrackTile({ const TrackTile({
Key? key, super.key,
this.index, this.index,
required this.track, required this.track,
this.selected = false, this.selected = false,
required this.playlist,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
this.onChanged, this.onChanged,
this.userPlaylist = false, this.userPlaylist = false,
this.playlistId, this.playlistId,
this.leadingActions, this.leadingActions,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final theme = Theme.of(context); final theme = Theme.of(context);
final blacklist = ref.watch(BlackListNotifier.provider); final blacklist = ref.watch(blacklistProvider);
final isBlackListed = useMemoized( final isBlackListed = useMemoized(
() => blacklist.contains( () => blacklist.contains(
@ -63,10 +66,10 @@ class TrackTile extends HookConsumerWidget {
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null); final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
final isPlaying = track.id == playlist.activeTrack?.id;
final isLoading = useState(false); final isLoading = useState(false);
final isPlaying = playlist.activeTrack?.id == track.id;
final isSelected = isPlaying || isLoading.value; final isSelected = isPlaying || isLoading.value;
return LayoutBuilder(builder: (context, constrains) { return LayoutBuilder(builder: (context, constrains) {
@ -135,8 +138,7 @@ class TrackTile extends HookConsumerWidget {
child: AspectRatio( child: AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: UniversalImage( child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString( path: (track.album?.images).asUrlString(
track.album?.images,
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
), ),
fit: BoxFit.cover, fit: BoxFit.cover,
@ -206,7 +208,7 @@ class TrackTile extends HookConsumerWidget {
Expanded( Expanded(
flex: 4, flex: 4,
child: switch (track.runtimeType) { child: switch (track.runtimeType) {
LocalTrack => Text( LocalTrack() => Text(
track.album!.name!, track.album!.name!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -230,16 +232,12 @@ class TrackTile extends HookConsumerWidget {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: track is LocalTrack child: track is LocalTrack
? Text( ? Text(
TypeConversionUtils.artists_X_String<Artist>( track.artists?.asString() ?? '',
track.artists ?? [],
),
) )
: ClipRect( : ClipRect(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 40), constraints: const BoxConstraints(maxHeight: 40),
child: TypeConversionUtils.artists_X_ClickableArtists( child: ArtistLink(artists: track.artists ?? []),
track.artists ?? [],
),
), ),
), ),
), ),

View File

@ -8,23 +8,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/track_tile/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/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/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_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TrackViewBodySection extends HookConsumerWidget { class TrackViewBodySection extends HookConsumerWidget {
const TrackViewBodySection({Key? key}) : super(key: key); const TrackViewBodySection({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks)); final trackViewState = ref.watch(trackViewProvider(props.tracks));
@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget {
loadingBuilder: (context) => Skeletonizer( loadingBuilder: (context) => Skeletonizer(
enabled: true, enabled: true,
child: TrackTile( child: TrackTile(
playlist: playlist,
track: FakeData.track, track: FakeData.track,
index: 0, index: 0,
), ),
@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget {
child: Column( child: Column(
children: List.generate( children: List.generate(
10, 10,
(index) => TrackTile(track: FakeData.track, index: index), (index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
), ),
), ),
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = tracks[index]; final track = tracks[index];
return TrackTile( return TrackTile(
playlist: playlist,
track: track, track: track,
index: index, index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!), selected: trackViewState.selectedTrackIds.contains(track.id!),
@ -125,6 +134,26 @@ class TrackViewBodySection extends HookConsumerWidget {
return; return;
} }
final isRemoteDevice =
await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(props.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await remotePlayback.load(
WebSocketLoadEventData(
tracks: tracks,
collectionId: props.collectionId,
initialIndex: index,
),
);
}
} else {
if (isActive || playlist.tracks.contains(track)) { if (isActive || playlist.tracks.contains(track)) {
await playlistNotifier.jumpToTrack(track); await playlistNotifier.jumpToTrack(track);
} else { } else {
@ -136,6 +165,7 @@ class TrackViewBodySection extends HookConsumerWidget {
); );
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
} }
}
}, },
); );
}, },

View File

@ -13,10 +13,10 @@ class TrackViewBodyHeaders extends HookConsumerWidget {
final FocusNode searchFocus; final FocusNode searchFocus;
const TrackViewBodyHeaders({ const TrackViewBodyHeaders({
Key? key, super.key,
required this.isFiltering, required this.isFiltering,
required this.searchFocus, required this.searchFocus,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {

View File

@ -13,7 +13,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
class TrackViewBodyOptions extends HookConsumerWidget { class TrackViewBodyOptions extends HookConsumerWidget {
const TrackViewBodyOptions({Key? key}) : super(key: key); const TrackViewBodyOptions({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -22,7 +22,7 @@ class TrackViewBodyOptions extends HookConsumerWidget {
ref.watch(downloadManagerProvider); ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final audioSource = final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource)); ref.watch(userPreferencesProvider.select((s) => s.audioSource));

View File

@ -1,18 +1,18 @@
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/provider/spotify/spotify.dart';
bool useIsUserPlaylist(WidgetRef ref, String playlistId) { bool useIsUserPlaylist(WidgetRef ref, String playlistId) {
final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref); final userPlaylistsQuery = ref.watch(favoritePlaylistsProvider);
final me = useQueries.user.me(ref); final me = ref.watch(meProvider);
return useMemoized( return useMemoized(
() => () =>
userPlaylistsQuery.data?.any((e) => userPlaylistsQuery.asData?.value.items.any((e) =>
e.id == playlistId && e.id == playlistId &&
me.data != null && me.asData?.value != null &&
e.owner?.id == me.data?.id) ?? e.owner?.id == me.asData?.value.id) ??
false, false,
[userPlaylistsQuery.data, playlistId, me.data], [userPlaylistsQuery.asData?.value, playlistId, me.asData?.value],
); );
} }

View File

@ -14,7 +14,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart';
class TrackViewFlexHeader extends HookConsumerWidget { class TrackViewFlexHeader extends HookConsumerWidget {
const TrackViewFlexHeader({Key? key}) : super(key: key); const TrackViewFlexHeader({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {

View File

@ -12,14 +12,14 @@ import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
class TrackViewHeaderActions extends HookConsumerWidget { class TrackViewHeaderActions extends HookConsumerWidget {
const TrackViewHeaderActions({Key? key}) : super(key: key); const TrackViewHeaderActions({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final isActive = playlist.collections.contains(props.collectionId); final isActive = playlist.collections.contains(props.collectionId);
@ -27,7 +27,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context);
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(authenticationProvider);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -6,8 +6,11 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -15,16 +18,16 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final PaletteColor color; final PaletteColor color;
final bool compact; final bool compact;
const TrackViewHeaderButtons({ const TrackViewHeaderButtons({
Key? key, super.key,
required this.color, required this.color,
this.compact = false, this.compact = false,
}) : super(key: key); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final isActive = playlist.collections.contains(props.collectionId); final isActive = playlist.collections.contains(props.collectionId);
@ -43,6 +46,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final allTracks = await props.pagination.onFetchAll(); final allTracks = await props.pagination.onFetchAll();
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData(
tracks: allTracks,
collectionId: props.collectionId,
initialIndex: Random().nextInt(allTracks.length)),
);
await remotePlayback.setShuffle(true);
} else {
await playlistNotifier.load( await playlistNotifier.load(
allTracks, allTracks,
autoPlay: true, autoPlay: true,
@ -50,6 +66,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
); );
await audioPlayer.setShuffle(true); await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
}
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -61,8 +78,21 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final allTracks = await props.pagination.onFetchAll(); final allTracks = await props.pagination.onFetchAll();
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData(
tracks: allTracks,
collectionId: props.collectionId,
),
);
} else {
await playlistNotifier.load(allTracks, autoPlay: true); await playlistNotifier.load(allTracks, autoPlay: true);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
}
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }

View File

@ -10,7 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/track_view_b
import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
class TrackView extends HookConsumerWidget { class TrackView extends HookConsumerWidget {
const TrackView({Key? key}) : super(key: key); const TrackView({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {

View File

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart' hide Page; import 'package:flutter/material.dart' hide Page;
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
@ -19,19 +18,6 @@ class PaginationProps {
required this.onRefresh, required this.onRefresh,
}); });
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,
onRefresh: query.refreshAll,
);
}
@override @override
operator ==(Object other) { operator ==(Object other) {
return other is PaginationProps && return other is PaginationProps &&

Some files were not shown because too many files have changed in this diff Show More