mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-05-08 16:24:36 +00:00
Merge branch 'dev' into dependabot/pub/dev/flutter_hooks-0.20.5
This commit is contained in:
commit
45deb2fb5a
2
.github/workflows/spotube-publish-binary.yml
vendored
2
.github/workflows/spotube-publish-binary.yml
vendored
@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
- name: Release to AUR
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v2.7.0
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v2.7.1
|
||||
with:
|
||||
pkgname: spotube-bin
|
||||
pkgbuild: aur-struct/PKGBUILD
|
||||
|
||||
17
.github/workflows/spotube-release-binary.yml
vendored
17
.github/workflows/spotube-release-binary.yml
vendored
@ -181,6 +181,7 @@ jobs:
|
||||
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ inputs.channel == 'stable' }}
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: Spotube-Release-Binaries
|
||||
@ -188,6 +189,16 @@ jobs:
|
||||
dist/Spotube-linux-x86_64.deb
|
||||
dist/Spotube-linux-x86_64.rpm
|
||||
dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ inputs.channel == 'nightly' }}
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: Spotube-Release-Binaries
|
||||
path: |
|
||||
dist/Spotube-linux-x86_64.deb
|
||||
dist/Spotube-linux-x86_64.rpm
|
||||
dist/spotube-linux-nightly-x86_64.tar.xz
|
||||
|
||||
- name: Debug With SSH When fails
|
||||
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
|
||||
@ -273,7 +284,7 @@ jobs:
|
||||
|
||||
macos:
|
||||
|
||||
runs-on: macos-12
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2.12.0
|
||||
@ -316,7 +327,7 @@ jobs:
|
||||
|
||||
- name: Package Macos App
|
||||
run: |
|
||||
python3 -m pip install setuptools
|
||||
brew install python-setuptools
|
||||
npm install -g appdmg
|
||||
mkdir -p build/${{ env.BUILD_VERSION }}
|
||||
appdmg appdmg.json build/Spotube-macos-universal.dmg
|
||||
@ -338,7 +349,7 @@ jobs:
|
||||
limit-access-to-actor: true
|
||||
|
||||
iOS:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2.10.0
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -2,11 +2,15 @@
|
||||
"cmake.configureOnOpen": false,
|
||||
"cSpell.words": [
|
||||
"acousticness",
|
||||
"Amoled",
|
||||
"Buildless",
|
||||
"danceability",
|
||||
"fuzzywuzzy",
|
||||
"instrumentalness",
|
||||
"Mpris",
|
||||
"riverpod",
|
||||
"Scrobblenaut",
|
||||
"skeletonizer",
|
||||
"speechiness",
|
||||
"Spotube",
|
||||
"winget"
|
||||
|
||||
170
.vscode/snippets.code-snippets
vendored
Normal file
170
.vscode/snippets.code-snippets
vendored
Normal 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(),",
|
||||
");"
|
||||
]
|
||||
},
|
||||
}
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@ -2,6 +2,38 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add endless playback support [#285](https://github.com/krtirtho/spotube/issues/285) ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e))
|
||||
* add getting started page ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716))
|
||||
* Add iOS background play support ([#1166](https://github.com/krtirtho/spotube/issues/1166)) ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e))
|
||||
* add songlink based track matching for youtube and open song link button ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974))
|
||||
* **playlist:** show confirmation before deleting user playlist [#1222](https://github.com/krtirtho/spotube/issues/1222) ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003))
|
||||
* Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609))
|
||||
* start radio support ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52))
|
||||
* **translations:** add Korean translation ([#1275](https://github.com/krtirtho/spotube/issues/1275)) ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9))
|
||||
* **translations:** Added Vietnamese ([#1135](https://github.com/krtirtho/spotube/issues/1135)) ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d))
|
||||
* **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2))
|
||||
* album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991))
|
||||
* **android:** audio issue when screen is off and broadcast audio session id ([#1221](https://github.com/krtirtho/spotube/issues/1221) & [#1247](https://github.com/krtirtho/spotube/issues/1247)) ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571)
|
||||
* **android:** only ask battery optimization once [#1252](https://github.com/krtirtho/spotube/issues/1252) ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3))
|
||||
* **android:** pressing back button in any other tab other than home exits the app ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde))
|
||||
* **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d))
|
||||
* cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5))
|
||||
* friends list not scrollable with mouse drag ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9))
|
||||
* no draggable scrollbar in playlist/album page [#1158](https://github.com/krtirtho/spotube/issues/1158) ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef))
|
||||
* non-banger songs breaking the queue if sources not found ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f))
|
||||
* track loading when not found in Youtube ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c))
|
||||
* **translations:** Update app_nl.arb ([#1168](https://github.com/krtirtho/spotube/issues/1168)) ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1))
|
||||
|
||||
## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27)
|
||||
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
|
||||
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
|
||||
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
|
||||
- [Your First Code Contribution](#your-first-code-contribution)
|
||||
- [Submit translations](#submit-translations)
|
||||
- [Submit Translations](#submit-translations)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
@ -123,16 +123,16 @@ Do the following:
|
||||
- Install Development dependencies in linux
|
||||
- Debian (>=12/Bookworm)/Ubuntu
|
||||
```bash
|
||||
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev
|
||||
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan
|
||||
```
|
||||
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
|
||||
- Arch/Manjaro
|
||||
```bash
|
||||
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify
|
||||
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
|
||||
```
|
||||
- Fedora
|
||||
```bash
|
||||
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel
|
||||
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns
|
||||
```
|
||||
- Clone the Repo
|
||||
- Create a `.env` in root of the project following the `.env.example` template
|
||||
|
||||
25
README.md
25
README.md
@ -7,12 +7,14 @@ eliminating the need for Spotify Premium
|
||||
|
||||
Btw it's not just another Electron app 😉
|
||||
|
||||
<a href="https://spotube.netlify.app"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
|
||||
<a href="https://spotube.krtirtho.dev"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
|
||||
<a href="https://discord.gg/uJ94vxB6vg"><img alt="Discord Server" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/social/discord-plural_vector.svg"></a>
|
||||
|
||||
<a href="https://patreon.com/krtirtho"><img alt="Support me on Patron" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/donate/patreon-singular_vector.svg"></a>
|
||||
<a href="https://www.buymeacoffee.com/krtirtho"><img alt="Buy me a Coffee" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/donate/buymeacoffee-singular_vector.svg"></a>
|
||||
|
||||
[](https://news.ycombinator.com/item?id=39066136)
|
||||
|
||||
<a href="https://opencollective.com/spotube"><img src="https://opencollective.com/spotube/donate/button.png?color=blue" alt="Donate to our Open Collective" height="45"></a>
|
||||
|
||||
---
|
||||
@ -136,6 +138,15 @@ This handy table lists all the methods you can use to install Spotube:
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Macos - <a href="https://brew.sh">Homebrew</a></td>
|
||||
<td>
|
||||
<pre lang="bash">
|
||||
brew tap krtirtho/apps
|
||||
brew install --cask spotube
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows - <a href="https://chocolatey.org">Chocolatey</a></td>
|
||||
<td>
|
||||
@ -193,6 +204,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
|
||||
1. [Piped](https://piped-docs.kavin.rocks/) - Piped is a privacy friendly alternative YouTube frontend, which is efficient and scalable by design.
|
||||
1. [YouTube](https://youtube.com/) - YouTube is an American online video-sharing platform headquartered in San Bruno, California. Three former PayPal employees—Chad Hurley, Steve Chen, and Jawed Karim—created the service in February 2005
|
||||
1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages
|
||||
1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content
|
||||
1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution
|
||||
1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users
|
||||
1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux
|
||||
@ -231,7 +243,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
|
||||
1. [flutter_hooks](https://github.com/rrousselGit/flutter_hooks) - A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse.
|
||||
1. [flutter_inappwebview](https://inappwebview.dev/) - A Flutter plugin that allows you to add an inline webview, to use an headless webview, and to open an in-app browser window.
|
||||
1. [flutter_native_splash](https://pub.dev/packages/flutter_native_splash) - Customize Flutter's default white native splash screen with background color and splash image. Supports dark mode, full screen, and more.
|
||||
1. [flutter_riverpod](https://riverpod.dev) - A simple way to access state from anywhere in your application while robust and testable.
|
||||
1. [flutter_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze.
|
||||
1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android.
|
||||
1. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files.
|
||||
1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets
|
||||
@ -240,7 +252,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
|
||||
1. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling.
|
||||
1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256.
|
||||
1. [hive_flutter](https://github.com/hivedb/hive/tree/master/hive_flutter) - Extension for Hive. Makes it easier to use Hive in Flutter apps.
|
||||
1. [hooks_riverpod](https://riverpod.dev) - A simple way to access state from anywhere in your application while robust and testable.
|
||||
1. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze.
|
||||
1. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser.
|
||||
1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests.
|
||||
1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera.
|
||||
@ -257,14 +269,12 @@ If you are concerned, you can [read the reason of choosing this license](https:/
|
||||
1. [path](https://pub.dev/packages/path) - A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web.
|
||||
1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories.
|
||||
1. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions.
|
||||
1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video
|
||||
1. [popover](https://github.com/minikin/popover) - A popover is a transient view that appears above other content onscreen when you tap a control or in an area.
|
||||
1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter
|
||||
1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget
|
||||
1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android.
|
||||
1. [skeleton_text](https://github.com/101Loop/Skeleton-Text) - A package that provides an easy way to add skeleton text loading animation in Flutter project. This project is a part of 101Loop community.
|
||||
1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet.
|
||||
1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API.
|
||||
1. [stroke_text](https://github.com/MohamedAbd0/stroke_text) - A Simple Flutter plugin for applying stroke (border) style to a text widget
|
||||
1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS
|
||||
1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos.
|
||||
@ -287,6 +297,9 @@ If you are concerned, you can [read the reason of choosing this license](https:/
|
||||
1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included).
|
||||
1. [win32_registry](https://win32.pub) - A package that provides a friendly Dart API for accessing the Windows Registry.
|
||||
1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app.
|
||||
1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications.
|
||||
1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too.
|
||||
1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API.
|
||||
1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation.
|
||||
1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied.
|
||||
1. [flutter_distributor](https://distributor.leanflutter.org) - A complete tool for packaging and publishing your Flutter apps.
|
||||
@ -297,7 +310,9 @@ If you are concerned, you can [read the reason of choosing this license](https:/
|
||||
1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes.
|
||||
1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information.
|
||||
1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting.
|
||||
1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features.
|
||||
1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development
|
||||
1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video
|
||||
1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark.
|
||||
1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter.
|
||||
1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item.
|
||||
|
||||
@ -25,6 +25,7 @@ linter:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
file_names: false
|
||||
avoid_renaming_method_parameters: false
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@ -34,3 +35,5 @@ analyzer:
|
||||
- patterns
|
||||
errors:
|
||||
invalid_annotation_target: ignore
|
||||
plugins:
|
||||
- custom_lint
|
||||
|
||||
26
bin/translated_messages.dart
Normal file
26
bin/translated_messages.dart
Normal file
@ -0,0 +1,26 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
void main(List<String> args) async {
|
||||
final translatedFile =
|
||||
jsonDecode(await File('tm.json').readAsString()) as Map<String, dynamic>;
|
||||
|
||||
for (final MapEntry(:key, :value) in translatedFile.entries) {
|
||||
print('Updating locale: $key');
|
||||
final file = File('lib/l10n/app_$key.arb');
|
||||
|
||||
final fileContent =
|
||||
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
|
||||
final newContent = {
|
||||
...fileContent,
|
||||
...value,
|
||||
};
|
||||
|
||||
await file.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(newContent),
|
||||
);
|
||||
|
||||
print('✅ Updated locale: $key');
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '12.0'
|
||||
platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@ -5,6 +5,9 @@ PODS:
|
||||
- Flutter
|
||||
- audio_session (0.0.1):
|
||||
- Flutter
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.4):
|
||||
@ -44,11 +47,13 @@ PODS:
|
||||
- file_selector_ios (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_inappwebview (0.0.1):
|
||||
- flutter_broadcasts (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview/Core (= 0.0.1)
|
||||
- flutter_inappwebview_ios (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview_ios/Core (= 0.0.1)
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_inappwebview/Core (0.0.1):
|
||||
- flutter_inappwebview_ios/Core (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_keyboard_visibility (0.0.1):
|
||||
@ -102,11 +107,13 @@ DEPENDENCIES:
|
||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
||||
- flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
|
||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
@ -142,6 +149,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/audio_service/ios"
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
bonsoir_darwin:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
@ -150,8 +159,10 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/file_selector_ios/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_inappwebview:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview/ios"
|
||||
flutter_broadcasts:
|
||||
:path: ".symlinks/plugins/flutter_broadcasts/ios"
|
||||
flutter_inappwebview_ios:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
||||
flutter_keyboard_visibility:
|
||||
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
||||
flutter_mailer:
|
||||
@ -191,13 +202,15 @@ SPEC CHECKSUMS:
|
||||
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
|
||||
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
||||
audio_session: 4f3e461722055d21515cf3261b64c973c062f345
|
||||
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
|
||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
||||
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62
|
||||
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
||||
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
@ -221,6 +234,6 @@ SPEC CHECKSUMS:
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
|
||||
PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd
|
||||
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
|
||||
@ -66,5 +66,11 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<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>
|
||||
</plist>
|
||||
@ -6,7 +6,7 @@ abstract class FakeData {
|
||||
static final Image image = Image()
|
||||
..height = 1
|
||||
..width = 1
|
||||
..url = "url";
|
||||
..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg";
|
||||
|
||||
static final Followers followers = Followers()
|
||||
..href = "text"
|
||||
|
||||
@ -353,10 +353,10 @@ abstract class LanguageLocals {
|
||||
// name: "Kongo",
|
||||
// nativeName: "KiKongo",
|
||||
// ),
|
||||
// "ko": const ISOLanguageName(
|
||||
// name: "Korean",
|
||||
// nativeName: "한국어 (韓國語), 조선말 (朝鮮語)",
|
||||
// ),
|
||||
"ko": const ISOLanguageName(
|
||||
name: "Korean",
|
||||
nativeName: "한국어 (韓國語), 조선말 (朝鮮語)",
|
||||
),
|
||||
// "ku": const ISOLanguageName(
|
||||
// name: "Kurdish",
|
||||
// nativeName: "Kurdî, كوردی",
|
||||
@ -637,10 +637,10 @@ abstract class LanguageLocals {
|
||||
// name: "Tajik",
|
||||
// nativeName: "тоҷикӣ, toğikī, تاجیکی",
|
||||
// ),
|
||||
// "th": const ISOLanguageName(
|
||||
// name: "Thai",
|
||||
// nativeName: "ไทย",
|
||||
// ),
|
||||
"th": const ISOLanguageName(
|
||||
name: "Thai",
|
||||
nativeName: "ไทย",
|
||||
),
|
||||
// "ti": const ISOLanguageName(
|
||||
// name: "Tigrinya",
|
||||
// nativeName: "ትግርኛ",
|
||||
|
||||
@ -4,7 +4,10 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart' hide Search;
|
||||
import 'package:spotube/models/spotify/recommendation_seeds.dart';
|
||||
import 'package:spotube/pages/album/album.dart';
|
||||
import 'package:spotube/pages/connect/connect.dart';
|
||||
import 'package:spotube/pages/connect/control/control.dart';
|
||||
import 'package:spotube/pages/getting_started/getting_started.dart';
|
||||
import 'package:spotube/pages/home/genres/genre_playlists.dart';
|
||||
import 'package:spotube/pages/home/genres/genres.dart';
|
||||
@ -96,8 +99,7 @@ final routerProvider = Provider((ref) {
|
||||
path: "result",
|
||||
pageBuilder: (context, state) => SpotubePage(
|
||||
child: PlaylistGenerateResultPage(
|
||||
state:
|
||||
state.extra as PlaylistGenerateResultRouteState,
|
||||
state: state.extra as GeneratePlaylistProviderInput,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -173,6 +175,21 @@ final routerProvider = Provider((ref) {
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/connect",
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: ConnectPage(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "control",
|
||||
pageBuilder: (context, state) {
|
||||
return const SpotubePage(
|
||||
child: ConnectControlPage(),
|
||||
);
|
||||
},
|
||||
)
|
||||
])
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@ -115,4 +115,10 @@ abstract class SpotubeIcons {
|
||||
static const github = SimpleIcons.github;
|
||||
static const openCollective = SimpleIcons.opencollective;
|
||||
static const anonymous = FeatherIcons.user;
|
||||
static const history = FeatherIcons.clock;
|
||||
static const connect = FeatherIcons.link;
|
||||
static const speaker = FeatherIcons.speaker;
|
||||
static const monitor = FeatherIcons.monitor;
|
||||
static const power = FeatherIcons.power;
|
||||
static const bluetooth = FeatherIcons.bluetooth;
|
||||
}
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/infinite_query.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/extensions/track.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/queries/album.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
extension FormattedAlbumType on AlbumType {
|
||||
String get formatted => name.replaceFirst(name[0], name[0].toUpperCase());
|
||||
@ -21,8 +23,8 @@ class AlbumCard extends HookConsumerWidget {
|
||||
final AlbumSimple album;
|
||||
const AlbumCard(
|
||||
this.album, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -31,47 +33,25 @@ class AlbumCard extends HookConsumerWidget {
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
final queryClient = useQueryClient();
|
||||
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() => playlist.containsCollection(album.id!),
|
||||
[playlist, album.id],
|
||||
);
|
||||
|
||||
final updating = useState(false);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
|
||||
|
||||
Future<List<Track>> fetchAllTrack() async {
|
||||
if (album.tracks != null && album.tracks!.isNotEmpty) {
|
||||
return album.tracks!
|
||||
.map((track) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||
.toList();
|
||||
return album.tracks!.map((track) => track.asTrack(album)).toList();
|
||||
}
|
||||
final job = AlbumQueries.tracksOfJob(album.id!);
|
||||
|
||||
final query = queryClient.createInfiniteQuery(
|
||||
job.queryKey,
|
||||
(page) => job.task(page, (spotify: spotify, album: album)),
|
||||
initialPage: 0,
|
||||
nextPage: job.nextPage,
|
||||
);
|
||||
|
||||
return await query.fetchAllTracks(
|
||||
getAllTracks: () async {
|
||||
final res = await spotify.albums.tracks(album.id!).all();
|
||||
return res
|
||||
.map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
|
||||
.toList();
|
||||
},
|
||||
);
|
||||
await ref.read(albumTracksProvider(album).future);
|
||||
return ref.read(albumTracksProvider(album).notifier).fetchAll();
|
||||
}
|
||||
|
||||
return PlaybuttonCard(
|
||||
imageUrl: TypeConversionUtils.image_X_UrlString(
|
||||
album.images,
|
||||
imageUrl: album.images.asUrlString(
|
||||
placeholder: ImagePlaceholder.collection,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
@ -80,7 +60,7 @@ class AlbumCard extends HookConsumerWidget {
|
||||
updating.value,
|
||||
title: album.name!,
|
||||
description:
|
||||
"${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
|
||||
"${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}",
|
||||
onTap: () {
|
||||
ServiceUtils.push(context, "/album/${album.id}", extra: album);
|
||||
},
|
||||
@ -95,8 +75,19 @@ class AlbumCard extends HookConsumerWidget {
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: fetchedTracks,
|
||||
collectionId: album.id!,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
|
||||
@ -1,38 +1,35 @@
|
||||
import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class ArtistAlbumList extends HookConsumerWidget {
|
||||
final String artistId;
|
||||
ArtistAlbumList(
|
||||
this.artistId, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
final logger = getLogger(ArtistAlbumList);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final albumsQuery = useQueries.artist.albumsOf(ref, artistId);
|
||||
final albumsQuery = ref.watch(artistAlbumsProvider(artistId));
|
||||
final albumsQueryNotifier =
|
||||
ref.watch(artistAlbumsProvider(artistId).notifier);
|
||||
|
||||
final albums = useMemoized(() {
|
||||
return albumsQuery.pages
|
||||
.expand<Album>((page) => page.items ?? const Iterable.empty())
|
||||
.toList();
|
||||
}, [albumsQuery.pages]);
|
||||
final albums = albumsQuery.asData?.value.items ?? [];
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return HorizontalPlaybuttonCardView<Album>(
|
||||
isLoadingNextPage: albumsQuery.isLoadingNextPage,
|
||||
hasNextPage: albumsQuery.hasNextPage,
|
||||
hasNextPage: albumsQuery.asData?.value.hasMore ?? false,
|
||||
items: albums,
|
||||
onFetchMore: albumsQuery.fetchNext,
|
||||
onFetchMore: albumsQueryNotifier.fetchMore,
|
||||
title: Text(
|
||||
context.l10n.albums,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
|
||||
@ -6,22 +6,21 @@ import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class ArtistCard extends HookConsumerWidget {
|
||||
final Artist artist;
|
||||
const ArtistCard(this.artist, {Key? key}) : super(key: key);
|
||||
const ArtistCard(this.artist, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final backgroundImage = UniversalImage.imageProvider(
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
artist.images,
|
||||
artist.images.asUrlString(
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
),
|
||||
);
|
||||
|
||||
85
lib/components/connect/connect_device.dart
Normal file
85
lib/components/connect/connect_device.dart
Normal file
@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/connect/clients.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
class ConnectDeviceButton extends HookConsumerWidget {
|
||||
const ConnectDeviceButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final ThemeData(:colorScheme) = Theme.of(context);
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final connectClients = ref.watch(connectClientsProvider);
|
||||
|
||||
return SizedBox(
|
||||
height: 40 * pixelRatio,
|
||||
child: Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
fit: StackFit.loose,
|
||||
children: [
|
||||
Center(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
ServiceUtils.push(context, "/connect");
|
||||
},
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
color: colorScheme.primaryContainer,
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (connectClients.asData?.value.resolvedService !=
|
||||
null) ...[
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.greenAccent,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
),
|
||||
const Gap(5),
|
||||
],
|
||||
Text(context.l10n.devices),
|
||||
if (connectClients.asData?.value.services.isNotEmpty ==
|
||||
true)
|
||||
Text(
|
||||
" (${connectClients.asData?.value.services.length})",
|
||||
style: TextStyle(
|
||||
color:
|
||||
colorScheme.onPrimaryContainer.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const Gap(35),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: IconButton.filled(
|
||||
icon: const Icon(SpotubeIcons.speaker),
|
||||
style: IconButton.styleFrom(
|
||||
visualDensity: VisualDensity.standard,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
),
|
||||
onPressed: () {
|
||||
ServiceUtils.push(context, "/connect");
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/components/connect/local_devices.dart
Normal file
60
lib/components/connect/local_devices.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,9 +8,9 @@ import 'package:spotube/provider/authentication_provider.dart';
|
||||
class TokenLoginForm extends HookConsumerWidget {
|
||||
final void Function()? onDone;
|
||||
const TokenLoginForm({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onDone,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -1,35 +1,28 @@
|
||||
import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class HomeFeaturedSection extends HookConsumerWidget {
|
||||
const HomeFeaturedSection({Key? key}) : super(key: key);
|
||||
const HomeFeaturedSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
|
||||
final playlists = useMemoized(
|
||||
() => featuredPlaylistsQuery.pages
|
||||
.whereType<Page<PlaylistSimple>>()
|
||||
.expand((page) => page.items ?? const <PlaylistSimple>[]),
|
||||
[featuredPlaylistsQuery.pages],
|
||||
);
|
||||
final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData &&
|
||||
!featuredPlaylistsQuery.isLoadingNextPage;
|
||||
final featuredPlaylists = ref.watch(featuredPlaylistsProvider);
|
||||
final featuredPlaylistsNotifier =
|
||||
ref.watch(featuredPlaylistsProvider.notifier);
|
||||
|
||||
return Skeletonizer(
|
||||
enabled: isLoadingFeaturedPlaylists,
|
||||
enabled: featuredPlaylists.isLoading,
|
||||
child: HorizontalPlaybuttonCardView<PlaylistSimple>(
|
||||
items: playlists.toList(),
|
||||
items: featuredPlaylists.asData?.value.items ?? [],
|
||||
title: Text(context.l10n.featured),
|
||||
isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage,
|
||||
hasNextPage: featuredPlaylistsQuery.hasNextPage,
|
||||
onFetchMore: featuredPlaylistsQuery.fetchNext,
|
||||
isLoadingNextPage: featuredPlaylists.isLoadingNextPage,
|
||||
hasNextPage: featuredPlaylists.asData?.value.hasMore ?? false,
|
||||
onFetchMore: featuredPlaylistsNotifier.fetchMore,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@ -8,15 +7,16 @@ import 'package:spotube/collections/fake.dart';
|
||||
import 'package:spotube/components/home/sections/friends/friend_item.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:spotube/models/spotify_friends.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class HomePageFriendsSection extends HookConsumerWidget {
|
||||
const HomePageFriendsSection({Key? key}) : super(key: key);
|
||||
const HomePageFriendsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final friendsQuery = useQueries.user.friendActivity(ref);
|
||||
final friends = friendsQuery.data?.friends ?? FakeData.friends.friends;
|
||||
final friendsQuery = ref.watch(friendsProvider);
|
||||
final friends =
|
||||
friendsQuery.asData?.value.friends ?? FakeData.friends.friends;
|
||||
|
||||
final groupCount = useBreakpointValue(
|
||||
sm: 3,
|
||||
@ -51,8 +51,8 @@ class HomePageFriendsSection extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
if (!friendsQuery.isLoading &&
|
||||
(!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) {
|
||||
if (friendsQuery.isLoading ||
|
||||
friendsQuery.asData?.value.friends.isEmpty == true) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: SizedBox.shrink(),
|
||||
);
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/models/spotify_friends.dart';
|
||||
@ -13,9 +11,9 @@ import 'package:spotube/provider/spotify_provider.dart';
|
||||
class FriendItem extends HookConsumerWidget {
|
||||
final SpotifyFriendActivity friend;
|
||||
const FriendItem({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.friend,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -24,7 +22,6 @@ class FriendItem extends HookConsumerWidget {
|
||||
colorScheme: colorScheme,
|
||||
) = Theme.of(context);
|
||||
|
||||
final queryClient = useQueryClient();
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
return Container(
|
||||
@ -86,15 +83,11 @@ class FriendItem extends HookConsumerWidget {
|
||||
..onTap = () async {
|
||||
context.push(
|
||||
"/${friend.track.context.path}",
|
||||
extra: !friend.track.context.path
|
||||
.startsWith("album")
|
||||
? null
|
||||
: await queryClient.fetchQuery<Album, dynamic>(
|
||||
"album/${friend.track.album.id}",
|
||||
() => spotify.albums.get(
|
||||
friend.track.album.id,
|
||||
),
|
||||
),
|
||||
extra:
|
||||
!friend.track.context.path.startsWith("album")
|
||||
? null
|
||||
: await spotify.albums
|
||||
.get(friend.track.context.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -110,12 +103,7 @@ class FriendItem extends HookConsumerWidget {
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
final album =
|
||||
await queryClient.fetchQuery<Album, dynamic>(
|
||||
"album/${friend.track.album.id}",
|
||||
() => spotify.albums.get(
|
||||
friend.track.album.id,
|
||||
),
|
||||
);
|
||||
await spotify.albums.get(friend.track.album.id);
|
||||
if (context.mounted) {
|
||||
context.push(
|
||||
"/album/${friend.track.album.id}",
|
||||
|
||||
@ -13,28 +13,26 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class HomeGenresSection extends HookConsumerWidget {
|
||||
const HomeGenresSection({Key? key}) : super(key: key);
|
||||
const HomeGenresSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
final recommendationMarket = ref.watch(
|
||||
userPreferencesProvider.select((s) => s.recommendationMarket),
|
||||
final categoriesQuery = ref.watch(categoriesProvider);
|
||||
final categories = useMemoized(
|
||||
() =>
|
||||
categoriesQuery.asData?.value
|
||||
.where((c) => (c.icons?.length ?? 0) > 0)
|
||||
.take(mediaQuery.mdAndDown ? 6 : 10)
|
||||
.toList() ??
|
||||
<Category>[],
|
||||
[mediaQuery.mdAndDown, categoriesQuery.asData?.value],
|
||||
);
|
||||
final categoriesQuery =
|
||||
useQueries.category.listAll(ref, recommendationMarket);
|
||||
|
||||
final categories = categoriesQuery.data
|
||||
?.where((c) => (c.icons?.length ?? 0) > 0)
|
||||
.take(mediaQuery.mdAndDown ? 6 : 10)
|
||||
.toList() ??
|
||||
<Category>[];
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
|
||||
@ -2,19 +2,19 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class HomeMadeForUserSection extends HookConsumerWidget {
|
||||
const HomeMadeForUserSection({Key? key}) : super(key: key);
|
||||
const HomeMadeForUserSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final madeForUser = useQueries.views.get(ref, "made-for-x-hub");
|
||||
final madeForUser = ref.watch(viewProvider("made-for-x-hub"));
|
||||
|
||||
return SliverList.builder(
|
||||
itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0,
|
||||
itemCount: madeForUser.asData?.value["content"]?["items"]?.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
final item = madeForUser.data?["content"]?["items"]?[index];
|
||||
final item = madeForUser.asData?.value["content"]?["items"]?[index];
|
||||
final playlists = item["content"]?["items"]
|
||||
?.where((itemL2) => itemL2["type"] == "playlist")
|
||||
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
|
||||
|
||||
@ -1,56 +1,35 @@
|
||||
import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class HomeNewReleasesSection extends HookConsumerWidget {
|
||||
const HomeNewReleasesSection({Key? key}) : super(key: key);
|
||||
const HomeNewReleasesSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
|
||||
final newReleases = useQueries.album.newReleases(ref);
|
||||
final userArtistsQuery = useQueries.artist.followedByMeAll(ref);
|
||||
final userArtists =
|
||||
userArtistsQuery.data?.map((s) => s.id!).toList() ?? const [];
|
||||
final newReleases = ref.watch(albumReleasesProvider);
|
||||
final newReleasesNotifier = ref.read(albumReleasesProvider.notifier);
|
||||
|
||||
final albums = useMemoized(
|
||||
() {
|
||||
final allReleases = newReleases.pages
|
||||
.whereType<Page<AlbumSimple>>()
|
||||
.expand((page) => page.items ?? const <AlbumSimple>[])
|
||||
.map((album) => TypeConversionUtils.simpleAlbum_X_Album(album));
|
||||
final albums = ref.watch(userArtistAlbumReleasesProvider);
|
||||
|
||||
final userArtistReleases = allReleases.where((album) {
|
||||
return album.artists
|
||||
?.any((artist) => userArtists.contains(artist.id!)) ==
|
||||
true;
|
||||
}).toList();
|
||||
|
||||
if (userArtistReleases.isEmpty) return allReleases.toList();
|
||||
return userArtistReleases;
|
||||
},
|
||||
[newReleases.pages],
|
||||
);
|
||||
|
||||
final hasNewReleases = newReleases.hasPageData &&
|
||||
userArtistsQuery.hasData &&
|
||||
!newReleases.isLoadingNextPage;
|
||||
|
||||
if (auth == null || !hasNewReleases) return const SizedBox.shrink();
|
||||
if (auth == null ||
|
||||
newReleases.isLoading ||
|
||||
newReleases.asData?.value.items.isEmpty == true) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return HorizontalPlaybuttonCardView<Album>(
|
||||
items: albums,
|
||||
title: Text(context.l10n.new_releases),
|
||||
isLoadingNextPage: newReleases.isLoadingNextPage,
|
||||
hasNextPage: newReleases.hasNextPage,
|
||||
onFetchMore: newReleases.fetchNext,
|
||||
hasNextPage: newReleases.asData?.value.hasMore ?? false,
|
||||
onFetchMore: newReleasesNotifier.fetchMore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ class MultiSelectField<T> extends HookWidget {
|
||||
final bool enabled;
|
||||
|
||||
const MultiSelectField({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.selectedOptions,
|
||||
required this.getValueForOption,
|
||||
@ -36,7 +36,7 @@ class MultiSelectField<T> extends HookWidget {
|
||||
this.dialogTitle,
|
||||
this.helperText,
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
Widget defaultSelectedOptionBuilder(T option) {
|
||||
return Chip(
|
||||
@ -134,14 +134,14 @@ class _MultiSelectDialog<T> extends HookWidget {
|
||||
final String? helperText;
|
||||
|
||||
const _MultiSelectDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.dialogTitle,
|
||||
required this.options,
|
||||
required this.getValueForOption,
|
||||
this.optionBuilder,
|
||||
this.initialSelection = const [],
|
||||
this.helperText,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -20,12 +20,12 @@ class RecommendationAttributeDials extends HookWidget {
|
||||
final double base;
|
||||
|
||||
const RecommendationAttributeDials({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.values,
|
||||
required this.onChanged,
|
||||
required this.title,
|
||||
this.base = 1,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -12,12 +12,12 @@ class RecommendationAttributeFields extends HookWidget {
|
||||
final Map<String, RecommendationAttribute>? presets;
|
||||
|
||||
const RecommendationAttributeFields({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.values,
|
||||
required this.onChanged,
|
||||
required this.title,
|
||||
this.presets,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -26,7 +26,7 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
|
||||
final SelectedItemDisplayType selectedItemDisplayType;
|
||||
|
||||
const SeedsMultiAutocomplete({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.seeds,
|
||||
required this.fetchSeeds,
|
||||
required this.autocompleteOptionBuilder,
|
||||
@ -35,7 +35,7 @@ class SeedsMultiAutocomplete<T extends Object> extends HookWidget {
|
||||
this.inputDecoration,
|
||||
this.enabled = true,
|
||||
this.selectedItemDisplayType = SelectedItemDisplayType.wrap,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -4,16 +4,16 @@ import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
|
||||
class SimpleTrackTile extends HookWidget {
|
||||
final Track track;
|
||||
final VoidCallback? onDelete;
|
||||
const SimpleTrackTile({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.track,
|
||||
this.onDelete,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -21,8 +21,7 @@ class SimpleTrackTile extends HookWidget {
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: UniversalImage(
|
||||
path: TypeConversionUtils.image_X_UrlString(
|
||||
track.album?.images,
|
||||
path: (track.album?.images).asUrlString(
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
),
|
||||
height: 40,
|
||||
|
||||
@ -4,7 +4,6 @@ import 'package:collection/collection.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/fake.dart';
|
||||
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
@ -13,44 +12,39 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
|
||||
import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/extensions/album_simple.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class UserAlbums extends HookConsumerWidget {
|
||||
const UserAlbums({Key? key}) : super(key: key);
|
||||
const UserAlbums({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
final albumsQuery = useQueries.album.ofMine(ref);
|
||||
final albumsQuery = ref.watch(favoriteAlbumsProvider);
|
||||
final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier);
|
||||
|
||||
final controller = useScrollController();
|
||||
|
||||
final searchText = useState('');
|
||||
|
||||
final allAlbums = useMemoized(
|
||||
() => albumsQuery.pages
|
||||
.expand((element) => element.items ?? <AlbumSimple>[]),
|
||||
[albumsQuery.pages],
|
||||
);
|
||||
|
||||
final albums = useMemoized(() {
|
||||
if (searchText.value.isEmpty) {
|
||||
return allAlbums;
|
||||
return albumsQuery.asData?.value.items ?? [];
|
||||
}
|
||||
return allAlbums
|
||||
.map((e) => (
|
||||
weightedRatio(e.name!, searchText.value),
|
||||
e,
|
||||
))
|
||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||
.where((e) => e.$1 > 50)
|
||||
.map((e) => e.$2)
|
||||
.toList();
|
||||
}, [allAlbums, searchText.value]);
|
||||
return albumsQuery.asData?.value.items
|
||||
.map((e) => (
|
||||
weightedRatio(e.name!, searchText.value),
|
||||
e,
|
||||
))
|
||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||
.where((e) => e.$1 > 50)
|
||||
.map((e) => e.$2)
|
||||
.toList() ??
|
||||
[];
|
||||
}, [albumsQuery.asData?.value, searchText.value]);
|
||||
|
||||
if (auth == null) {
|
||||
return const AnonymousFallback();
|
||||
@ -60,7 +54,7 @@ class UserAlbums extends HookConsumerWidget {
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await albumsQuery.refresh();
|
||||
ref.invalidate(favoriteAlbumsProvider);
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Scaffold(
|
||||
@ -85,7 +79,7 @@ class UserAlbums extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
controller: controller,
|
||||
child: Skeletonizer(
|
||||
enabled: albumsQuery.pages.isEmpty,
|
||||
enabled: albumsQuery.isLoading,
|
||||
child: Center(
|
||||
child: Wrap(
|
||||
runSpacing: 20,
|
||||
@ -93,7 +87,8 @@ class UserAlbums extends HookConsumerWidget {
|
||||
runAlignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (albumsQuery.pages.isEmpty)
|
||||
if (albumsQuery.asData?.value == null ||
|
||||
albumsQuery.asData!.value.items.isEmpty)
|
||||
...List.generate(
|
||||
10,
|
||||
(index) => AlbumCard(FakeData.album),
|
||||
@ -103,16 +98,17 @@ class UserAlbums extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [NotFound()],
|
||||
),
|
||||
for (final album in albums)
|
||||
AlbumCard(
|
||||
TypeConversionUtils.simpleAlbum_X_Album(album),
|
||||
),
|
||||
if (albums.isNotEmpty && albumsQuery.hasNextPage)
|
||||
Waypoint(
|
||||
controller: controller,
|
||||
isGrid: true,
|
||||
onTouchEdge: albumsQuery.fetchNext,
|
||||
child: AlbumCard(FakeData.album),
|
||||
for (final album in albums) AlbumCard(album.toAlbum()),
|
||||
if (albums.isNotEmpty &&
|
||||
albumsQuery.asData?.value.hasMore == true)
|
||||
Skeletonizer(
|
||||
enabled: true,
|
||||
child: Waypoint(
|
||||
controller: controller,
|
||||
isGrid: true,
|
||||
onTouchEdge: albumsQueryNotifier.fetchMore,
|
||||
child: AlbumCard(FakeData.album),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@ -13,22 +13,22 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class UserArtists extends HookConsumerWidget {
|
||||
const UserArtists({Key? key}) : super(key: key);
|
||||
const UserArtists({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
|
||||
final artistQuery = useQueries.artist.followedByMeAll(ref);
|
||||
final artistQuery = ref.watch(followedArtistsProvider);
|
||||
|
||||
final searchText = useState('');
|
||||
|
||||
final filteredArtists = useMemoized(() {
|
||||
final artists = artistQuery.data ?? [];
|
||||
final artists = artistQuery.asData?.value.items ?? [];
|
||||
|
||||
if (searchText.value.isEmpty) {
|
||||
return artists.toList();
|
||||
@ -42,7 +42,7 @@ class UserArtists extends HookConsumerWidget {
|
||||
.where((e) => e.$1 > 50)
|
||||
.map((e) => e.$2)
|
||||
.toList();
|
||||
}, [artistQuery.data, searchText.value]);
|
||||
}, [artistQuery.asData?.value.items, searchText.value]);
|
||||
|
||||
final controller = useScrollController();
|
||||
|
||||
@ -66,7 +66,7 @@ class UserArtists extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
body: artistQuery.data?.isEmpty == true
|
||||
body: artistQuery.asData?.value.items.isEmpty == true
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
@ -80,7 +80,7 @@ class UserArtists extends HookConsumerWidget {
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await artistQuery.refresh();
|
||||
ref.invalidate(followedArtistsProvider);
|
||||
},
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
@ -109,8 +109,9 @@ class UserArtists extends HookConsumerWidget {
|
||||
)
|
||||
]
|
||||
: filteredArtists
|
||||
.mapIndexed((index, artist) =>
|
||||
ArtistCard(artist))
|
||||
.mapIndexed(
|
||||
(index, artist) => ArtistCard(artist),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
||||
@ -7,7 +7,7 @@ import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
|
||||
class UserDownloads extends HookConsumerWidget {
|
||||
const UserDownloads({Key? key}) : super(key: key);
|
||||
const UserDownloads({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -4,18 +4,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/services/download_manager/download_status.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class DownloadItem extends HookConsumerWidget {
|
||||
final Track track;
|
||||
const DownloadItem({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.track,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -51,16 +52,15 @@ class DownloadItem extends HookConsumerWidget {
|
||||
child: UniversalImage(
|
||||
height: 40,
|
||||
width: 40,
|
||||
path: TypeConversionUtils.image_X_UrlString(
|
||||
track.album?.images,
|
||||
path: (track.album?.images).asUrlString(
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(track.name ?? ''),
|
||||
subtitle: TypeConversionUtils.artists_X_ClickableArtists(
|
||||
track.artists ?? <Artist>[],
|
||||
subtitle: ArtistLink(
|
||||
artists: track.artists ?? <Artist>[],
|
||||
mainAxisAlignment: WrapAlignment.start,
|
||||
),
|
||||
trailing: isQueryingSourceInfo
|
||||
|
||||
@ -21,12 +21,13 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/track.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
|
||||
|
||||
const supportedAudioTypes = [
|
||||
@ -111,7 +112,7 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
|
||||
final tracks = filesWithMetadata
|
||||
.map(
|
||||
(fileWithMetadata) => LocalTrack.fromTrack(
|
||||
track: TypeConversionUtils.localTrack_X_Track(
|
||||
track: Track().fromFile(
|
||||
fileWithMetadata["file"],
|
||||
metadata: fileWithMetadata["metadata"],
|
||||
art: fileWithMetadata["art"],
|
||||
@ -129,7 +130,7 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
|
||||
});
|
||||
|
||||
class UserLocalTracks extends HookConsumerWidget {
|
||||
const UserLocalTracks({Key? key}) : super(key: key);
|
||||
const UserLocalTracks({super.key});
|
||||
|
||||
Future<void> playLocalTracks(
|
||||
WidgetRef ref,
|
||||
@ -159,7 +160,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final trackSnapshot = ref.watch(localTracksProvider);
|
||||
final isPlaylistPlaying =
|
||||
playlist.containsTracks(trackSnapshot.value ?? []);
|
||||
playlist.containsTracks(trackSnapshot.asData?.value ?? []);
|
||||
|
||||
final searchController = useTextEditingController();
|
||||
useValueListenable(searchController);
|
||||
@ -176,13 +177,13 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
children: [
|
||||
const SizedBox(width: 10),
|
||||
FilledButton(
|
||||
onPressed: trackSnapshot.value != null
|
||||
onPressed: trackSnapshot.asData?.value != null
|
||||
? () async {
|
||||
if (trackSnapshot.value?.isNotEmpty == true) {
|
||||
if (trackSnapshot.asData?.value.isNotEmpty == true) {
|
||||
if (!isPlaylistPlaying) {
|
||||
await playLocalTracks(
|
||||
ref,
|
||||
trackSnapshot.value!,
|
||||
trackSnapshot.asData!.value,
|
||||
);
|
||||
} else {
|
||||
// TODO: Remove stop capability
|
||||
@ -217,7 +218,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
FilledButton(
|
||||
child: const Icon(SpotubeIcons.refresh),
|
||||
onPressed: () {
|
||||
ref.refresh(localTracksProvider);
|
||||
ref.invalidate(localTracksProvider);
|
||||
},
|
||||
)
|
||||
],
|
||||
@ -242,7 +243,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
return sortedTracks
|
||||
.map((e) => (
|
||||
weightedRatio(
|
||||
"${e.name} - ${TypeConversionUtils.artists_X_String<Artist>(e.artists ?? [])}",
|
||||
"${e.name} - ${e.artists?.asString() ?? ""}",
|
||||
searchController.text,
|
||||
),
|
||||
e,
|
||||
@ -269,7 +270,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
return Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.refresh(localTracksProvider);
|
||||
ref.invalidate(localTracksProvider);
|
||||
},
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
@ -282,12 +283,17 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
trackSnapshot.isLoading ? 5 : filteredTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (trackSnapshot.isLoading) {
|
||||
return TrackTile(track: FakeData.track, index: index);
|
||||
return TrackTile(
|
||||
playlist: playlist,
|
||||
track: FakeData.track,
|
||||
index: index,
|
||||
);
|
||||
}
|
||||
|
||||
final track = filteredTracks[index];
|
||||
return TrackTile(
|
||||
index: index,
|
||||
playlist: playlist,
|
||||
track: track,
|
||||
userPlaylist: false,
|
||||
onTap: () async {
|
||||
@ -310,8 +316,11 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
enabled: true,
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) =>
|
||||
TrackTile(track: FakeData.track, index: index),
|
||||
itemBuilder: (context, index) => TrackTile(
|
||||
track: FakeData.track,
|
||||
index: index,
|
||||
playlist: playlist,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -17,10 +17,10 @@ import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class UserPlaylists extends HookConsumerWidget {
|
||||
const UserPlaylists({Key? key}) : super(key: key);
|
||||
const UserPlaylists({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -28,13 +28,9 @@ class UserPlaylists extends HookConsumerWidget {
|
||||
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
|
||||
final playlistsQuery = useQueries.playlist.ofMine(ref);
|
||||
|
||||
final pagePlaylists = useMemoized(
|
||||
() => playlistsQuery.pages
|
||||
.expand((page) => page.items?.toList() ?? <PlaylistSimple>[]),
|
||||
[playlistsQuery.pages],
|
||||
);
|
||||
final playlistsQuery = ref.watch(favoritePlaylistsProvider);
|
||||
final playlistsQueryNotifier =
|
||||
ref.watch(favoritePlaylistsProvider.notifier);
|
||||
|
||||
final likedTracksPlaylist = useMemoized(
|
||||
() => PlaylistSimple()
|
||||
@ -58,12 +54,12 @@ class UserPlaylists extends HookConsumerWidget {
|
||||
if (searchText.value.isEmpty) {
|
||||
return [
|
||||
likedTracksPlaylist,
|
||||
...pagePlaylists,
|
||||
...?playlistsQuery.asData?.value.items,
|
||||
];
|
||||
}
|
||||
return [
|
||||
likedTracksPlaylist,
|
||||
...pagePlaylists,
|
||||
...?playlistsQuery.asData?.value.items,
|
||||
]
|
||||
.map((e) => (weightedRatio(e.name!, searchText.value), e))
|
||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||
@ -71,7 +67,7 @@ class UserPlaylists extends HookConsumerWidget {
|
||||
.map((e) => e.$2)
|
||||
.toList();
|
||||
},
|
||||
[pagePlaylists, searchText.value],
|
||||
[playlistsQuery, searchText.value],
|
||||
);
|
||||
|
||||
final controller = useScrollController();
|
||||
@ -81,7 +77,9 @@ class UserPlaylists extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: playlistsQuery.refresh,
|
||||
onRefresh: () async {
|
||||
ref.invalidate(favoritePlaylistsProvider);
|
||||
},
|
||||
child: SafeArea(
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
@ -132,14 +130,14 @@ class UserPlaylists extends HookConsumerWidget {
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
if (playlists.isNotEmpty && index == playlists.length) {
|
||||
if (!playlistsQuery.hasNextPage) {
|
||||
if (playlistsQuery.asData?.value.hasMore != true) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Waypoint(
|
||||
controller: controller,
|
||||
isGrid: true,
|
||||
onTouchEdge: playlistsQuery.fetchNext,
|
||||
onTouchEdge: playlistsQueryNotifier.fetchMore,
|
||||
child: Skeletonizer(
|
||||
enabled: true,
|
||||
child: PlaylistCard(FakeData.playlistSimple),
|
||||
|
||||
@ -17,7 +17,7 @@ class ZoomControls extends HookWidget {
|
||||
final String unit;
|
||||
|
||||
const ZoomControls({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.min,
|
||||
@ -27,7 +27,7 @@ class ZoomControls extends HookWidget {
|
||||
this.decreaseIcon = const Icon(SpotubeIcons.zoomOut),
|
||||
this.direction = Axis.horizontal,
|
||||
this.unit = "%",
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart' hide Offset;
|
||||
import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/player/player_actions.dart';
|
||||
@ -13,28 +12,33 @@ import 'package:spotube/components/player/player_queue.dart';
|
||||
import 'package:spotube/components/player/volume_slider.dart';
|
||||
import 'package:spotube/components/shared/animated_gradient.dart';
|
||||
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
|
||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/panels/sliding_up_panel.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart';
|
||||
import 'package:spotube/hooks/utils/use_palette_color.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:spotube/provider/volume_provider.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
||||
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class PlayerView extends HookConsumerWidget {
|
||||
final PanelController panelController;
|
||||
final ScrollController scrollController;
|
||||
const PlayerView({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.panelController,
|
||||
required this.scrollController,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -43,9 +47,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||
(value) => value.activeTrack,
|
||||
));
|
||||
final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||
(value) => value.activeTrack is LocalTrack,
|
||||
));
|
||||
final isLocalTrack = currentTrack is LocalTrack;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
useEffect(() {
|
||||
@ -58,8 +60,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
}, [mediaQuery.lgAndUp]);
|
||||
|
||||
String albumArt = useMemoized(
|
||||
() => TypeConversionUtils.image_X_UrlString(
|
||||
currentTrack?.album?.images,
|
||||
() => (currentTrack?.album?.images).asUrlString(
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
[currentTrack?.album?.images],
|
||||
@ -138,26 +139,31 @@ class PlayerView extends HookConsumerWidget {
|
||||
onPressed: panelController.close,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Assets.logos.songlink.image(
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
tooltip: context.l10n.song_link,
|
||||
onPressed: currentTrack == null
|
||||
? null
|
||||
: () {
|
||||
final url =
|
||||
"https://song.link/s/${currentTrack.id}";
|
||||
if (currentTrack is YoutubeSourcedTrack)
|
||||
TextButton.icon(
|
||||
icon: Assets.logos.songlinkTransparent.image(
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: bodyTextColor,
|
||||
),
|
||||
label: Text(context.l10n.song_link),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: bodyTextColor,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: () {
|
||||
final url =
|
||||
"https://song.link/s/${currentTrack.id}";
|
||||
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.info, size: 18),
|
||||
tooltip: context.l10n.details,
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: bodyTextColor),
|
||||
foregroundColor: bodyTextColor,
|
||||
),
|
||||
onPressed: currentTrack == null
|
||||
? null
|
||||
: () {
|
||||
@ -233,19 +239,15 @@ class PlayerView extends HookConsumerWidget {
|
||||
),
|
||||
if (isLocalTrack)
|
||||
Text(
|
||||
TypeConversionUtils.artists_X_String<
|
||||
Artist>(
|
||||
currentTrack?.artists ?? [],
|
||||
),
|
||||
currentTrack.artists?.asString() ?? "",
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: bodyTextColor,
|
||||
),
|
||||
)
|
||||
else
|
||||
TypeConversionUtils
|
||||
.artists_X_ClickableArtists(
|
||||
currentTrack?.artists ?? [],
|
||||
ArtistLink(
|
||||
artists: currentTrack?.artists ?? [],
|
||||
textStyle:
|
||||
theme.textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
@ -301,10 +303,25 @@ class PlayerView extends HookConsumerWidget {
|
||||
.height *
|
||||
.7,
|
||||
),
|
||||
builder: (context) {
|
||||
return const PlayerQueue(
|
||||
floating: false);
|
||||
},
|
||||
builder: (context) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final playlist = ref.watch(
|
||||
ProxyPlaylistNotifier
|
||||
.provider,
|
||||
);
|
||||
final playlistNotifier =
|
||||
ref.read(
|
||||
ProxyPlaylistNotifier
|
||||
.notifier,
|
||||
);
|
||||
return PlayerQueue
|
||||
.fromProxyPlaylistNotifier(
|
||||
floating: false,
|
||||
playlist: playlist,
|
||||
notifier: playlistNotifier,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
: null),
|
||||
@ -362,11 +379,21 @@ class PlayerView extends HookConsumerWidget {
|
||||
enabledThumbRadius: 8,
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: VolumeSlider(
|
||||
fullWidth: true,
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Consumer(builder: (context, ref, _) {
|
||||
final volume = ref.watch(volumeProvider);
|
||||
return VolumeSlider(
|
||||
fullWidth: true,
|
||||
value: volume,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(volumeProvider.notifier)
|
||||
.setVolume(value);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -3,12 +3,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart' hide Offset;
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
|
||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
||||
import 'package:spotube/components/shared/heart_button.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
@ -17,7 +16,6 @@ import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/sleep_timer_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlayerActions extends HookConsumerWidget {
|
||||
final MainAxisAlignment mainAxisAlignment;
|
||||
@ -29,13 +27,12 @@ class PlayerActions extends HookConsumerWidget {
|
||||
this.floatingQueue = true,
|
||||
this.showQueue = true,
|
||||
this.extraActions,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
final logger = getLogger(PlayerActions);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final isLocalTrack = playlist.activeTrack is LocalTrack;
|
||||
ref.watch(downloadManagerProvider);
|
||||
@ -58,10 +55,8 @@ class PlayerActions extends HookConsumerWidget {
|
||||
(element) =>
|
||||
element.name == playlist.activeTrack?.name &&
|
||||
element.album?.name == playlist.activeTrack?.album?.name &&
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
element.artists ?? []) ==
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
playlist.activeTrack?.artists ?? []),
|
||||
element.artists?.asString() ==
|
||||
playlist.activeTrack?.artists?.asString(),
|
||||
) ==
|
||||
true;
|
||||
}, [localTracks, playlist.activeTrack]);
|
||||
|
||||
@ -21,8 +21,8 @@ class PlayerControls extends HookConsumerWidget {
|
||||
PlayerControls({
|
||||
this.palette,
|
||||
this.compact = false,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
final logger = getLogger(PlayerControls);
|
||||
|
||||
@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
|
||||
onPressed: playlist.isFetching == true
|
||||
? null
|
||||
: () async {
|
||||
switch (await audioPlayer.loopMode) {
|
||||
case PlaybackLoopMode.all:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.one);
|
||||
break;
|
||||
case PlaybackLoopMode.one:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.none);
|
||||
break;
|
||||
case PlaybackLoopMode.none:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.all);
|
||||
break;
|
||||
}
|
||||
audioPlayer.setLoopMode(
|
||||
switch (loopMode) {
|
||||
PlaybackLoopMode.all =>
|
||||
PlaybackLoopMode.one,
|
||||
PlaybackLoopMode.one =>
|
||||
PlaybackLoopMode.none,
|
||||
PlaybackLoopMode.none =>
|
||||
PlaybackLoopMode.all,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
@ -19,8 +19,8 @@ class PlayerOverlay extends HookConsumerWidget {
|
||||
|
||||
const PlayerOverlay({
|
||||
required this.albumArt,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget {
|
||||
width: double.infinity,
|
||||
color: Colors.transparent,
|
||||
child: PlayerTrackDetails(
|
||||
albumArt: albumArt,
|
||||
track: playlist.activeTrack,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
|
||||
@ -5,30 +5,56 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlayerQueue extends HookConsumerWidget {
|
||||
final bool floating;
|
||||
final ProxyPlaylist playlist;
|
||||
|
||||
final Future<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({
|
||||
this.floating = true,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
required this.playlist,
|
||||
required this.onJump,
|
||||
required this.onRemove,
|
||||
required this.onReorder,
|
||||
required this.onStop,
|
||||
super.key,
|
||||
});
|
||||
|
||||
PlayerQueue.fromProxyPlaylistNotifier({
|
||||
this.floating = true,
|
||||
required this.playlist,
|
||||
required ProxyPlaylistNotifier notifier,
|
||||
super.key,
|
||||
}) : onJump = notifier.jumpToTrack,
|
||||
onRemove = notifier.removeTrack,
|
||||
onReorder = notifier.moveTrack,
|
||||
onStop = notifier.stop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final controller = useAutoScrollController();
|
||||
final searchText = useState('');
|
||||
|
||||
@ -44,7 +70,6 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
topRight: Radius.circular(10),
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final headlineColor = theme.textTheme.headlineSmall?.color;
|
||||
|
||||
final filteredTracks = useMemoized(
|
||||
@ -55,7 +80,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
return tracks
|
||||
.map((e) => (
|
||||
weightedRatio(
|
||||
'${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}',
|
||||
'${e.name!} - ${e.artists?.asString() ?? ""}',
|
||||
searchText.value,
|
||||
),
|
||||
e
|
||||
@ -83,201 +108,204 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
return const NotFound(vertical: true);
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 15,
|
||||
sigmaY: 15,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 5.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
||||
if (!isSearching.value) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
isSearching.value = false;
|
||||
searchText.value = '';
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
if (!floating)
|
||||
Container(
|
||||
height: 5,
|
||||
width: 100,
|
||||
margin: const EdgeInsets.only(bottom: 5, top: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: headlineColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (mediaQuery.mdAndUp || !isSearching.value) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
context.l10n.tracks_in_queue(tracks.length),
|
||||
style: TextStyle(
|
||||
color: headlineColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
if (mediaQuery.mdAndUp || isSearching.value)
|
||||
TextField(
|
||||
onChanged: (value) {
|
||||
searchText.value = value;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.search,
|
||||
isDense: true,
|
||||
prefixIcon: mediaQuery.smAndDown
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
isSearching.value = false;
|
||||
searchText.value = '';
|
||||
},
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size.square(20),
|
||||
),
|
||||
)
|
||||
: const Icon(SpotubeIcons.filter),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: 40,
|
||||
maxWidth: mediaQuery.smAndDown
|
||||
? mediaQuery.size.width - 40
|
||||
: 300,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(SpotubeIcons.filter),
|
||||
onPressed: () {
|
||||
isSearching.value = !isSearching.value;
|
||||
},
|
||||
),
|
||||
if (mediaQuery.mdAndUp || !isSearching.value) ...[
|
||||
const SizedBox(width: 10),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor:
|
||||
theme.scaffoldBackgroundColor.withOpacity(0.5),
|
||||
foregroundColor: theme.textTheme.headlineSmall?.color,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(SpotubeIcons.playlistRemove),
|
||||
const SizedBox(width: 5),
|
||||
Text(context.l10n.clear_all),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
playlistNotifier.stop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (!isSearching.value && searchText.value.isEmpty)
|
||||
Flexible(
|
||||
child: ReorderableListView.builder(
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
playlistNotifier.moveTrack(oldIndex, newIndex);
|
||||
},
|
||||
scrollController: controller,
|
||||
itemCount: tracks.length,
|
||||
shrinkWrap: true,
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderStart: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
onReorderEnd: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
itemBuilder: (context, i) {
|
||||
final track = tracks.elementAt(i);
|
||||
return AutoScrollTag(
|
||||
key: ValueKey(i),
|
||||
controller: controller,
|
||||
index: i,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: TrackTile(
|
||||
index: i,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
if (playlist.activeTrack?.id == track.id) {
|
||||
return;
|
||||
}
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
},
|
||||
leadingActions: [
|
||||
ReorderableDragStartListener(
|
||||
index: i,
|
||||
child: const Icon(SpotubeIcons.dragHandle),
|
||||
),
|
||||
],
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 15,
|
||||
sigmaY: 15,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 5.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
||||
if (!isSearching.value) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
isSearching.value = false;
|
||||
searchText.value = '';
|
||||
}
|
||||
},
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
if (!floating)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Container(
|
||||
height: 5,
|
||||
width: 100,
|
||||
margin: const EdgeInsets.only(bottom: 5, top: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: headlineColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: InterScrollbar(
|
||||
controller: controller,
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
),
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: !isSearching.value,
|
||||
title: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 10,
|
||||
sigmaY: 10,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: mediaQuery.mdAndUp || !isSearching.value
|
||||
? Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
context.l10n
|
||||
.tracks_in_queue(tracks.length),
|
||||
style: TextStyle(
|
||||
color: headlineColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (mediaQuery.mdAndUp || isSearching.value)
|
||||
TextField(
|
||||
onChanged: (value) {
|
||||
searchText.value = value;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.search,
|
||||
isDense: true,
|
||||
prefixIcon: mediaQuery.smAndDown
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
isSearching.value = false;
|
||||
searchText.value = '';
|
||||
},
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size.square(20),
|
||||
),
|
||||
)
|
||||
: const Icon(SpotubeIcons.filter),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: 40,
|
||||
maxWidth: mediaQuery.smAndDown
|
||||
? mediaQuery.size.width - 40
|
||||
: 300,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(SpotubeIcons.filter),
|
||||
onPressed: () {
|
||||
isSearching.value = !isSearching.value;
|
||||
},
|
||||
),
|
||||
if (mediaQuery.mdAndUp || !isSearching.value) ...[
|
||||
const SizedBox(width: 10),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: theme.scaffoldBackgroundColor
|
||||
.withOpacity(0.5),
|
||||
foregroundColor:
|
||||
theme.textTheme.headlineSmall?.color,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(SpotubeIcons.playlistRemove),
|
||||
const SizedBox(width: 5),
|
||||
Text(context.l10n.clear_all),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
playlistNotifier.stop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SliverGap(10),
|
||||
SliverReorderableList(
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
playlistNotifier.moveTrack(oldIndex, newIndex);
|
||||
},
|
||||
itemCount: filteredTracks.length,
|
||||
onReorderStart: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
onReorderEnd: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
itemBuilder: (context, i) {
|
||||
final track = filteredTracks.elementAt(i);
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: TrackTile(
|
||||
index: i,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
if (playlist.activeTrack?.id == track.id) {
|
||||
return;
|
||||
}
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
},
|
||||
return AutoScrollTag(
|
||||
key: ValueKey<int>(i),
|
||||
controller: controller,
|
||||
index: i,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: TrackTile(
|
||||
playlist: playlist,
|
||||
index: i,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
if (playlist.activeTrack?.id == track.id) {
|
||||
return;
|
||||
}
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
},
|
||||
leadingActions: [
|
||||
if (!isSearching.value &&
|
||||
searchText.value.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: ReorderableDragStartListener(
|
||||
index: i,
|
||||
child: const Icon(
|
||||
SpotubeIcons.dragHandle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SliverGap(100),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,17 +4,18 @@ import 'package:spotify/spotify.dart';
|
||||
|
||||
import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/components/shared/links/link_text.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlayerTrackDetails extends HookConsumerWidget {
|
||||
final String? albumArt;
|
||||
final Color? color;
|
||||
const PlayerTrackDetails({Key? key, this.albumArt, this.color})
|
||||
: super(key: key);
|
||||
final Track? track;
|
||||
const PlayerTrackDetails({super.key, this.color, this.track});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: UniversalImage(
|
||||
path: albumArt ?? "",
|
||||
path: (track?.album?.images)
|
||||
.asUrlString(placeholder: ImagePlaceholder.albumArt),
|
||||
placeholder: Assets.albumPlaceholder.path,
|
||||
),
|
||||
),
|
||||
@ -55,9 +57,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
playback.activeTrack?.artists ?? [],
|
||||
),
|
||||
playback.activeTrack?.artists?.asString() ?? "",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall!.copyWith(color: color),
|
||||
)
|
||||
@ -76,8 +76,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: color),
|
||||
),
|
||||
TypeConversionUtils.artists_X_ClickableArtists(
|
||||
playback.activeTrack?.artists ?? [],
|
||||
ArtistLink(
|
||||
artists: playback.activeTrack?.artists ?? [],
|
||||
onRouteChange: (route) {
|
||||
ServiceUtils.push(context, route);
|
||||
},
|
||||
|
||||
@ -10,6 +10,7 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
@ -24,7 +25,6 @@ import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/piped.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
final sourceInfoToIconMap = {
|
||||
YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)),
|
||||
@ -45,9 +45,9 @@ final sourceInfoToIconMap = {
|
||||
class SiblingTracksSheet extends HookConsumerWidget {
|
||||
final bool floating;
|
||||
const SiblingTracksSheet({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.floating = true,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -67,7 +67,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
||||
).trim();
|
||||
|
||||
final defaultSearchTerm =
|
||||
"$title - ${TypeConversionUtils.artists_X_String<Artist>(playlist.activeTrack?.artists ?? [])}";
|
||||
"$title - ${playlist.activeTrack?.artists?.asString() ?? ""}";
|
||||
final searchController = useTextEditingController(
|
||||
text: defaultSearchTerm,
|
||||
);
|
||||
|
||||
@ -3,37 +3,39 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/provider/volume_provider.dart';
|
||||
|
||||
class VolumeSlider extends HookConsumerWidget {
|
||||
final bool fullWidth;
|
||||
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
const VolumeSlider({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.fullWidth = false,
|
||||
}) : super(key: key);
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final volume = ref.watch(volumeProvider);
|
||||
final volumeNotifier = ref.watch(volumeProvider.notifier);
|
||||
|
||||
var slider = Listener(
|
||||
onPointerSignal: (event) async {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) {
|
||||
final value = volume - .2;
|
||||
volumeNotifier.setVolume(value < 0 ? 0 : value);
|
||||
final newValue = value - .2;
|
||||
onChanged(newValue < 0 ? 0 : newValue);
|
||||
} else {
|
||||
final value = volume + .2;
|
||||
volumeNotifier.setVolume(value > 1 ? 1 : value);
|
||||
final newValue = value + .2;
|
||||
onChanged(newValue > 1 ? 1 : newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: volume,
|
||||
onChanged: volumeNotifier.setVolume,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
return Row(
|
||||
@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
volume == 0
|
||||
value == 0
|
||||
? SpotubeIcons.volumeMute
|
||||
: volume <= 0.2
|
||||
: value <= 0.2
|
||||
? SpotubeIcons.volumeLow
|
||||
: volume <= 0.6
|
||||
: value <= 0.6
|
||||
? SpotubeIcons.volumeMedium
|
||||
: SpotubeIcons.volumeHigh,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () {
|
||||
if (volume == 0) {
|
||||
volumeNotifier.setVolume(1);
|
||||
if (value == 0) {
|
||||
onChanged(1);
|
||||
} else {
|
||||
volumeNotifier.setVolume(0);
|
||||
onChanged(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@ -1,77 +1,59 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||
import 'package:spotube/extensions/infinite_query.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlaylistCard extends HookConsumerWidget {
|
||||
final PlaylistSimple playlist;
|
||||
const PlaylistCard(
|
||||
this.playlist, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final queryClient = QueryClient.of(context);
|
||||
final tracks = useState<List<TrackSimple>?>(null);
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() => playlistQueue.containsCollection(playlist.id!),
|
||||
[playlistQueue, playlist.id],
|
||||
);
|
||||
|
||||
final updating = useState(false);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final me = useQueries.user.me(ref);
|
||||
final me = ref.watch(meProvider);
|
||||
|
||||
Future<List<Track>> fetchAllTracks() async {
|
||||
if (playlist.id == 'user-liked-tracks') {
|
||||
return await queryClient.fetchQuery(
|
||||
"user-liked-tracks",
|
||||
() => useQueries.playlist.likedTracks(spotify),
|
||||
) ??
|
||||
[];
|
||||
return await ref.read(likedTracksProvider.future);
|
||||
}
|
||||
|
||||
final query = queryClient.createInfiniteQuery<List<Track>, dynamic, int>(
|
||||
"playlist-tracks/${playlist.id}",
|
||||
(page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!),
|
||||
initialPage: 0,
|
||||
nextPage: useQueries.playlist.tracksOfQueryNextPage,
|
||||
);
|
||||
await ref.read(playlistTracksProvider(playlist.id!).future);
|
||||
|
||||
return await query.fetchAllTracks(
|
||||
getAllTracks: () async {
|
||||
final res =
|
||||
await spotify.playlists.getTracksByPlaylistId(playlist.id!).all();
|
||||
return res.toList();
|
||||
},
|
||||
);
|
||||
return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll();
|
||||
}
|
||||
|
||||
return PlaybuttonCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
title: playlist.name!,
|
||||
description: playlist.description,
|
||||
imageUrl: TypeConversionUtils.image_X_UrlString(
|
||||
playlist.images,
|
||||
imageUrl: playlist.images.asUrlString(
|
||||
placeholder: ImagePlaceholder.collection,
|
||||
),
|
||||
isPlaying: isPlaylistPlaying,
|
||||
isLoading:
|
||||
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
|
||||
isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null,
|
||||
isOwner: playlist.owner?.id == me.asData?.value.id &&
|
||||
me.asData?.value.id != null,
|
||||
onTap: () {
|
||||
ServiceUtils.push(
|
||||
context,
|
||||
@ -92,9 +74,19 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(playlist.id!);
|
||||
tracks.value = fetchedTracks;
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: fetchedTracks,
|
||||
collectionId: playlist.id!,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(playlist.id!);
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
updating.value = false;
|
||||
@ -112,10 +104,9 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
|
||||
playlistNotifier.addTracks(fetchedTracks);
|
||||
playlistNotifier.addCollection(playlist.id!);
|
||||
tracks.value = fetchedTracks;
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
content: Text("Added ${tracks.value?.length} tracks to queue"),
|
||||
content: Text("Added ${fetchedTracks.length} tracks to queue"),
|
||||
action: SnackBarAction(
|
||||
label: "Undo",
|
||||
onPressed: () {
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:form_validator/form_validator.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
@ -13,21 +14,19 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/services/mutations/mutations.dart';
|
||||
import 'package:spotube/services/mutations/playlist.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
/// Track ids to add to the playlist
|
||||
final List<String> trackIds;
|
||||
final String? playlistId;
|
||||
PlaylistCreateDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.trackIds = const [],
|
||||
this.playlistId,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
@ -37,13 +36,16 @@ class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: HookBuilder(builder: (context) {
|
||||
final userPlaylists = useQueries.playlist.ofMine(ref);
|
||||
final userPlaylists = ref.watch(favoritePlaylistsProvider);
|
||||
final playlist = ref.watch(playlistProvider(playlistId ?? ""));
|
||||
final playlistNotifier =
|
||||
ref.watch(playlistProvider(playlistId ?? "").notifier);
|
||||
|
||||
final updatingPlaylist = useMemoized(
|
||||
() => userPlaylists.pages
|
||||
.expand((p) => p.items ?? <PlaylistSimple>[])
|
||||
() => userPlaylists.asData?.value.items
|
||||
.firstWhereOrNull((playlist) => playlist.id == playlistId),
|
||||
[
|
||||
userPlaylists.pages,
|
||||
userPlaylists.asData?.value.items,
|
||||
playlistId,
|
||||
],
|
||||
);
|
||||
@ -84,28 +86,10 @@ class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
}
|
||||
}, [scaffold, l10n, theme]);
|
||||
|
||||
final playlistCreateMutation = useMutations.playlist.create(
|
||||
ref,
|
||||
trackIds: trackIds,
|
||||
onData: (value) {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onError: onError,
|
||||
);
|
||||
|
||||
final playlistUpdateMutation = useMutations.playlist.update(
|
||||
ref,
|
||||
playlistId: playlistId,
|
||||
onData: (value) {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onError: onError,
|
||||
);
|
||||
|
||||
Future<void> onCreate() async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
|
||||
final PlaylistCRUDVariables payload = (
|
||||
final PlaylistInput payload = (
|
||||
playlistName: playlistName.text,
|
||||
collaborative: collaborative.value,
|
||||
public: public.value,
|
||||
@ -118,9 +102,14 @@ class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
if (isUpdatingPlaylist) {
|
||||
await playlistUpdateMutation.mutate(payload);
|
||||
await playlistNotifier.modify(payload, onError);
|
||||
} else {
|
||||
await playlistCreateMutation.mutate(payload);
|
||||
await playlistNotifier.create(payload, onError);
|
||||
}
|
||||
|
||||
if (context.mounted &&
|
||||
!ref.read(playlistProvider(playlistId ?? "")).hasError) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,7 +127,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: onCreate,
|
||||
onPressed: playlist.isLoading ? null : onCreate,
|
||||
child: Text(
|
||||
isUpdatingPlaylist
|
||||
? context.l10n.update
|
||||
@ -174,8 +163,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
children: [
|
||||
UniversalImage(
|
||||
path: field.value?.path ??
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
updatingPlaylist?.images,
|
||||
(updatingPlaylist?.images).asUrlString(
|
||||
placeholder: ImagePlaceholder.collection,
|
||||
),
|
||||
height: 200,
|
||||
@ -275,7 +263,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
class PlaylistCreateDialogButton extends HookConsumerWidget {
|
||||
const PlaylistCreateDialogButton({Key? key}) : super(key: key);
|
||||
const PlaylistCreateDialogButton({super.key});
|
||||
|
||||
showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
|
||||
showDialog(
|
||||
|
||||
@ -14,18 +14,20 @@ import 'package:spotube/components/player/player_controls.dart';
|
||||
import 'package:spotube/components/player/volume_slider.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart' hide volumeProvider;
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
import 'package:spotube/provider/volume_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class BottomPlayer extends HookConsumerWidget {
|
||||
BottomPlayer({Key? key}) : super(key: key);
|
||||
BottomPlayer({super.key});
|
||||
|
||||
final logger = getLogger(BottomPlayer);
|
||||
@override
|
||||
@ -34,13 +36,13 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final layoutMode =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||
final remoteControl = ref.watch(connectProvider);
|
||||
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
String albumArt = useMemoized(
|
||||
() => playlist.activeTrack?.album?.images?.isNotEmpty == true
|
||||
? TypeConversionUtils.image_X_UrlString(
|
||||
playlist.activeTrack?.album?.images,
|
||||
? (playlist.activeTrack?.album?.images).asUrlString(
|
||||
index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1,
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
)
|
||||
@ -74,7 +76,9 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
|
||||
Expanded(
|
||||
child: PlayerTrackDetails(track: playlist.activeTrack),
|
||||
),
|
||||
// controls
|
||||
Flexible(
|
||||
flex: 3,
|
||||
@ -122,10 +126,20 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
Container(
|
||||
height: 40,
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: const VolumeSlider(),
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Consumer(builder: (context, ref, _) {
|
||||
final volume = ref.watch(volumeProvider);
|
||||
return VolumeSlider(
|
||||
fullWidth: true,
|
||||
value: volume,
|
||||
onChanged: (value) {
|
||||
ref.read(volumeProvider.notifier).setVolume(value);
|
||||
},
|
||||
);
|
||||
}),
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -11,16 +11,16 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class Sidebar extends HookConsumerWidget {
|
||||
final int? selectedIndex;
|
||||
@ -31,8 +31,8 @@ class Sidebar extends HookConsumerWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onSelectedIndexChanged,
|
||||
required this.child,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
static Widget brandLogo() {
|
||||
return Container(
|
||||
@ -195,7 +195,7 @@ class Sidebar extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
class SidebarHeader extends HookWidget {
|
||||
const SidebarHeader({Key? key}) : super(key: key);
|
||||
const SidebarHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -234,18 +234,17 @@ class SidebarHeader extends HookWidget {
|
||||
|
||||
class SidebarFooter extends HookConsumerWidget {
|
||||
const SidebarFooter({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final me = useQueries.user.me(ref);
|
||||
final data = me.data;
|
||||
final me = ref.watch(meProvider);
|
||||
final data = me.asData?.value;
|
||||
|
||||
final avatarImg = TypeConversionUtils.image_X_UrlString(
|
||||
data?.images,
|
||||
final avatarImg = (data?.images).asUrlString(
|
||||
index: (data?.images?.length ?? 1) - 1,
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
);
|
||||
|
||||
@ -23,8 +23,8 @@ class SpotubeNavigationBar extends HookConsumerWidget {
|
||||
const SpotubeNavigationBar({
|
||||
required this.selectedIndex,
|
||||
required this.onSelectedIndexChanged,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -8,9 +8,9 @@ import 'package:system_theme/system_theme.dart';
|
||||
class SpotubeColor extends Color {
|
||||
final String name;
|
||||
|
||||
const SpotubeColor(int color, {required this.name}) : super(color);
|
||||
const SpotubeColor(super.color, {required this.name});
|
||||
|
||||
const SpotubeColor.from(int value, {required this.name}) : super(value);
|
||||
const SpotubeColor.from(super.value, {required this.name});
|
||||
|
||||
factory SpotubeColor.fromString(String string) {
|
||||
final slices = string.split(":");
|
||||
@ -44,7 +44,7 @@ final Set<SpotubeColor> colorsMap = {
|
||||
};
|
||||
|
||||
class ColorSchemePickerDialog extends HookConsumerWidget {
|
||||
const ColorSchemePickerDialog({Key? key}) : super(key: key);
|
||||
const ColorSchemePickerDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -119,8 +119,8 @@ class ColorTile extends StatelessWidget {
|
||||
this.onPressed,
|
||||
this.tooltip = "",
|
||||
this.isCompact = false,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
factory ColorTile.compact({
|
||||
required Color color,
|
||||
|
||||
@ -12,13 +12,13 @@ class Action extends StatelessWidget {
|
||||
final bool isExpanded;
|
||||
final Color? backgroundColor;
|
||||
const Action({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.isExpanded = true,
|
||||
this.backgroundColor,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
|
||||
class AdaptiveSelectTile<T> extends HookWidget {
|
||||
@ -38,11 +39,22 @@ class AdaptiveSelectTile<T> extends HookWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final rawControl = DropdownButton<T>(
|
||||
items: options,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
menuMaxHeight: mediaQuery.size.height * 0.6,
|
||||
final rawControl = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: DropdownButton<T>(
|
||||
items: options,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
menuMaxHeight: mediaQuery.size.height * 0.6,
|
||||
underline: const SizedBox.shrink(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
icon: const Icon(SpotubeIcons.angleDown),
|
||||
dropdownColor: theme.colorScheme.secondaryContainer,
|
||||
),
|
||||
);
|
||||
final controlPlaceholder = useMemoized(
|
||||
() => options
|
||||
|
||||
@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
class AnimateGradient extends HookWidget {
|
||||
const AnimateGradient({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.primaryColors,
|
||||
required this.secondaryColors,
|
||||
this.child,
|
||||
@ -17,8 +17,7 @@ class AnimateGradient extends HookWidget {
|
||||
this.reverse = true,
|
||||
}) : assert(primaryColors.length >= 2),
|
||||
assert(primaryColors.length == secondaryColors.length),
|
||||
_controller = controller,
|
||||
super(key: key);
|
||||
_controller = controller;
|
||||
|
||||
/// [controller]: pass this to have a fine control over the [Animation]
|
||||
final AnimationController? _controller;
|
||||
|
||||
@ -11,12 +11,12 @@ class CompactSearch extends HookWidget {
|
||||
final Color? iconColor;
|
||||
|
||||
const CompactSearch({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onChanged,
|
||||
this.placeholder = "Search...",
|
||||
this.icon = SpotubeIcons.search,
|
||||
this.iconColor,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -5,7 +5,7 @@ import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
|
||||
class ConfirmDownloadDialog extends StatelessWidget {
|
||||
const ConfirmDownloadDialog({Key? key}) : super(key: key);
|
||||
const ConfirmDownloadDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -82,7 +82,7 @@ class ConfirmDownloadDialog extends StatelessWidget {
|
||||
|
||||
class BulletPoint extends StatelessWidget {
|
||||
final String text;
|
||||
const BulletPoint(this.text, {Key? key}) : super(key: key);
|
||||
const BulletPoint(this.text, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -5,7 +5,7 @@ import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
|
||||
class PipedDownDialog extends HookConsumerWidget {
|
||||
const PipedDownDialog({Key? key}) : super(key: key);
|
||||
const PipedDownDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
@ -8,9 +7,8 @@ import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class PlaylistAddTrackDialog extends HookConsumerWidget {
|
||||
/// The id of the playlist this dialog was opened from
|
||||
@ -19,33 +17,40 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
|
||||
const PlaylistAddTrackDialog({
|
||||
required this.tracks,
|
||||
required this.openFromPlaylist,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final ThemeData(:textTheme) = Theme.of(context);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final userPlaylists = useQueries.playlist.ofMineAll(ref);
|
||||
final userPlaylists = ref.watch(favoritePlaylistsProvider);
|
||||
final favoritePlaylistsNotifier =
|
||||
ref.watch(favoritePlaylistsProvider.notifier);
|
||||
|
||||
final me = useQueries.user.me(ref);
|
||||
final me = ref.watch(meProvider);
|
||||
|
||||
final filteredPlaylists = useMemoized(
|
||||
() =>
|
||||
userPlaylists.data
|
||||
?.where(
|
||||
userPlaylists.asData?.value.items
|
||||
.where(
|
||||
(playlist) =>
|
||||
playlist.owner?.id != null &&
|
||||
playlist.owner!.id == me.data?.id &&
|
||||
playlist.owner!.id == me.asData?.value.id &&
|
||||
playlist.id != openFromPlaylist,
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
[userPlaylists.data, me.data?.id, openFromPlaylist],
|
||||
[userPlaylists.asData?.value, me.asData?.value.id, openFromPlaylist],
|
||||
);
|
||||
|
||||
final playlistsCheck = useState(<String, bool>{});
|
||||
final queryClient = useQueryClient();
|
||||
|
||||
useEffect(() {
|
||||
if (userPlaylists.asData?.value != null) {
|
||||
favoritePlaylistsNotifier.fetchAll();
|
||||
}
|
||||
return null;
|
||||
}, [userPlaylists.asData?.value]);
|
||||
|
||||
Future<void> onAdd() async {
|
||||
final selectedPlaylists = playlistsCheck.value.entries
|
||||
@ -54,21 +59,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
|
||||
|
||||
await Future.wait(
|
||||
selectedPlaylists.map(
|
||||
(playlistId) => spotify.playlists.addTracks(
|
||||
tracks
|
||||
.map(
|
||||
(track) => track.uri!,
|
||||
)
|
||||
.toList(),
|
||||
playlistId),
|
||||
(playlistId) => favoritePlaylistsNotifier.addTracks(
|
||||
playlistId,
|
||||
tracks.map((e) => e.id!).toList(),
|
||||
),
|
||||
),
|
||||
).then((_) => Navigator.pop(context, true));
|
||||
|
||||
await queryClient.refreshQueries(
|
||||
selectedPlaylists
|
||||
.map((playlistId) => "playlist-tracks/$playlistId")
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
@ -109,8 +105,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
|
||||
return CheckboxListTile(
|
||||
secondary: CircleAvatar(
|
||||
backgroundImage: UniversalImage.imageProvider(
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
playlist.images,
|
||||
playlist.images.asUrlString(
|
||||
placeholder: ImagePlaceholder.collection,
|
||||
),
|
||||
),
|
||||
|
||||
@ -8,8 +8,7 @@ final replaceDownloadedFileState = StateProvider<bool?>((ref) => null);
|
||||
|
||||
class ReplaceDownloadedDialog extends ConsumerWidget {
|
||||
final Track track;
|
||||
const ReplaceDownloadedDialog({required this.track, Key? key})
|
||||
: super(key: key);
|
||||
const ReplaceDownloadedDialog({required this.track, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
70
lib/components/shared/dialogs/select_device_dialog.dart
Normal file
70
lib/components/shared/dialogs/select_device_dialog.dart
Normal 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;
|
||||
}
|
||||
@ -2,20 +2,20 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/components/shared/links/hyper_link.dart';
|
||||
import 'package:spotube/components/shared/links/link_text.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
|
||||
class TrackDetailsDialog extends HookWidget {
|
||||
final Track track;
|
||||
const TrackDetailsDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.track,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -24,8 +24,8 @@ class TrackDetailsDialog extends HookWidget {
|
||||
|
||||
final detailsMap = {
|
||||
context.l10n.title: track.name!,
|
||||
context.l10n.artist: TypeConversionUtils.artists_X_ClickableArtists(
|
||||
track.artists ?? <Artist>[],
|
||||
context.l10n.artist: ArtistLink(
|
||||
artists: track.artists ?? <Artist>[],
|
||||
mainAxisAlignment: WrapAlignment.start,
|
||||
textStyle: const TextStyle(color: Colors.blue),
|
||||
),
|
||||
|
||||
@ -10,12 +10,12 @@ class ExpandableSearchField extends StatelessWidget {
|
||||
final FocusNode searchFocus;
|
||||
|
||||
const ExpandableSearchField({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.isFiltering,
|
||||
required this.onChangeFiltering,
|
||||
required this.searchController,
|
||||
required this.searchFocus,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -60,12 +60,12 @@ class ExpandableSearchButton extends StatelessWidget {
|
||||
final ValueChanged<bool>? onPressed;
|
||||
|
||||
const ExpandableSearchButton({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.isFiltering,
|
||||
required this.searchFocus,
|
||||
this.icon = const Icon(SpotubeIcons.filter),
|
||||
this.onPressed,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -8,9 +8,9 @@ import 'package:spotube/utils/service_utils.dart';
|
||||
class AnonymousFallback extends ConsumerWidget {
|
||||
final Widget? child;
|
||||
const AnonymousFallback({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -3,7 +3,7 @@ import 'package:spotube/collections/assets.gen.dart';
|
||||
|
||||
class NotFound extends StatelessWidget {
|
||||
final bool vertical;
|
||||
const NotFound({Key? key, this.vertical = false}) : super(key: key);
|
||||
const NotFound({super.key, this.vertical = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@ -8,8 +6,7 @@ import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/scrobbler_provider.dart';
|
||||
import 'package:spotube/services/mutations/mutations.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
class HeartButton extends HookConsumerWidget {
|
||||
final bool isLiked;
|
||||
@ -23,8 +20,8 @@ class HeartButton extends HookConsumerWidget {
|
||||
this.color,
|
||||
this.tooltip,
|
||||
this.icon,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -60,90 +57,50 @@ class HeartButton extends HookConsumerWidget {
|
||||
|
||||
typedef UseTrackToggleLike = ({
|
||||
bool isLiked,
|
||||
Mutation<bool, dynamic, bool> toggleTrackLike,
|
||||
Query<User?, dynamic> me,
|
||||
Future<void> Function(Track track) toggleTrackLike,
|
||||
});
|
||||
|
||||
UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
|
||||
final me = useQueries.user.me(ref);
|
||||
|
||||
final savedTracks = useQueries.playlist.likedTracksQuery(ref);
|
||||
final savedTracks = ref.watch(likedTracksProvider);
|
||||
final savedTracksNotifier = ref.watch(likedTracksProvider.notifier);
|
||||
|
||||
final isLiked = useMemoized(
|
||||
() => savedTracks.data?.any((element) => element.id == track.id) ?? false,
|
||||
[savedTracks.data, track.id],
|
||||
() =>
|
||||
savedTracks.asData?.value.any((element) => element.id == track.id) ??
|
||||
false,
|
||||
[savedTracks.asData?.value, track.id],
|
||||
);
|
||||
|
||||
final mounted = useIsMounted();
|
||||
|
||||
final scrobblerNotifier = ref.read(scrobblerProvider.notifier);
|
||||
|
||||
final toggleTrackLike = useMutations.track.toggleFavorite(
|
||||
ref,
|
||||
track.id!,
|
||||
onMutate: (isLiked) {
|
||||
if (isLiked) {
|
||||
savedTracks.setData(
|
||||
savedTracks.data
|
||||
?.where((element) => element.id != track.id)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
savedTracks.setData(
|
||||
[
|
||||
...?savedTracks.data,
|
||||
track,
|
||||
],
|
||||
);
|
||||
}
|
||||
return isLiked;
|
||||
},
|
||||
onData: (isLiked, recoveryData) async {
|
||||
await savedTracks.refresh();
|
||||
if (isLiked) {
|
||||
return (
|
||||
isLiked: isLiked,
|
||||
toggleTrackLike: (track) async {
|
||||
await savedTracksNotifier.toggleFavorite(track);
|
||||
|
||||
if (!isLiked) {
|
||||
await scrobblerNotifier.love(track);
|
||||
} else {
|
||||
await scrobblerNotifier.unlove(track);
|
||||
}
|
||||
},
|
||||
onError: (payload, isLiked) {
|
||||
if (!mounted()) return;
|
||||
|
||||
if (isLiked != true) {
|
||||
savedTracks.setData(
|
||||
savedTracks.data
|
||||
?.where((element) => element.id != track.id)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
savedTracks.setData(
|
||||
[
|
||||
...?savedTracks.data,
|
||||
track,
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (isLiked: isLiked, toggleTrackLike: toggleTrackLike, me: me);
|
||||
}
|
||||
|
||||
class TrackHeartButton extends HookConsumerWidget {
|
||||
final Track track;
|
||||
const TrackHeartButton({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.track,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final savedTracks = useQueries.playlist.likedTracksQuery(ref);
|
||||
final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
|
||||
final savedTracks = ref.watch(likedTracksProvider);
|
||||
final me = ref.watch(meProvider);
|
||||
final (:isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
|
||||
|
||||
if (me.isLoading || !me.hasData) {
|
||||
if (me.isLoading) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
@ -152,104 +109,9 @@ class TrackHeartButton extends HookConsumerWidget {
|
||||
? context.l10n.remove_from_favorites
|
||||
: context.l10n.save_as_favorite,
|
||||
isLiked: isLiked,
|
||||
onPressed: savedTracks.hasData
|
||||
onPressed: savedTracks.asData?.value != null
|
||||
? () {
|
||||
toggleTrackLike.mutate(isLiked);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaylistHeartButton extends HookConsumerWidget {
|
||||
final PlaylistSimple playlist;
|
||||
final IconData? icon;
|
||||
final ValueChanged<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);
|
||||
toggleTrackLike(track);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
@ -24,13 +24,12 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
|
||||
required this.hasNextPage,
|
||||
required this.onFetchMore,
|
||||
required this.isLoadingNextPage,
|
||||
Key? key,
|
||||
}) : assert(
|
||||
super.key,
|
||||
}) : assert(
|
||||
items is List<PlaylistSimple> ||
|
||||
items is List<Album> ||
|
||||
items is List<Artist>,
|
||||
),
|
||||
super(key: key);
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -85,11 +84,11 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
|
||||
return switch (item.runtimeType) {
|
||||
PlaylistSimple =>
|
||||
return switch (item) {
|
||||
PlaylistSimple() =>
|
||||
PlaylistCard(item as PlaylistSimple),
|
||||
Album => AlbumCard(item as Album),
|
||||
Artist => Padding(
|
||||
Album() => AlbumCard(item as Album),
|
||||
Artist() => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12.0),
|
||||
child: ArtistCard(item as Artist),
|
||||
|
||||
@ -7,8 +7,8 @@ class HoverBuilder extends HookWidget {
|
||||
const HoverBuilder({
|
||||
required this.builder,
|
||||
this.permanentState,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -20,8 +20,8 @@ class UniversalImage extends HookWidget {
|
||||
this.placeholder,
|
||||
this.fit,
|
||||
this.scale = 1,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
static ImageProvider imageProvider(
|
||||
String path, {
|
||||
|
||||
@ -11,13 +11,13 @@ class AnchorButton<T> extends HookWidget {
|
||||
|
||||
const AnchorButton(
|
||||
this.text, {
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onTap,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.style = const TextStyle(),
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
57
lib/components/shared/links/artist_link.dart
Normal file
57
lib/components/shared/links/artist_link.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -13,12 +13,12 @@ class Hyperlink extends StatelessWidget {
|
||||
const Hyperlink(
|
||||
this.text,
|
||||
this.url, {
|
||||
Key? key,
|
||||
super.key,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.style = const TextStyle(),
|
||||
this.maxLines,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -15,14 +15,14 @@ class LinkText<T> extends StatelessWidget {
|
||||
const LinkText(
|
||||
this.text,
|
||||
this.route, {
|
||||
Key? key,
|
||||
super.key,
|
||||
this.textAlign,
|
||||
this.extra,
|
||||
this.overflow,
|
||||
this.style = const TextStyle(),
|
||||
this.maxLines,
|
||||
this.push = false,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -26,8 +26,10 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
|
||||
final double? titleWidth;
|
||||
final Widget? title;
|
||||
|
||||
final bool _sliver;
|
||||
|
||||
const PageWindowTitleBar({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.actions,
|
||||
this.title,
|
||||
this.toolbarOpacity = 1,
|
||||
@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
|
||||
this.titleTextStyle,
|
||||
this.titleWidth,
|
||||
this.toolbarTextStyle,
|
||||
}) : super(key: key);
|
||||
}) : _sliver = false,
|
||||
pinned = false,
|
||||
floating = false,
|
||||
snap = false,
|
||||
stretch = false;
|
||||
|
||||
final bool pinned;
|
||||
final bool floating;
|
||||
final bool snap;
|
||||
final bool stretch;
|
||||
|
||||
const PageWindowTitleBar.sliver({
|
||||
super.key,
|
||||
this.actions,
|
||||
this.title,
|
||||
this.backgroundColor,
|
||||
this.actionsIconTheme,
|
||||
this.automaticallyImplyLeading = false,
|
||||
this.centerTitle,
|
||||
this.foregroundColor,
|
||||
this.leading,
|
||||
this.leadingWidth,
|
||||
this.titleSpacing,
|
||||
this.titleTextStyle,
|
||||
this.titleWidth,
|
||||
this.toolbarTextStyle,
|
||||
this.pinned = false,
|
||||
this.floating = false,
|
||||
this.snap = false,
|
||||
this.stretch = false,
|
||||
}) : _sliver = true,
|
||||
toolbarOpacity = 1;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
if (widget._sliver) {
|
||||
return SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final hasFullscreen =
|
||||
mediaQuery.size.width == constraints.crossAxisExtent;
|
||||
final hasLeadingOrCanPop =
|
||||
widget.leading != null || Navigator.canPop(context);
|
||||
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
left: DesktopTools.platform.isMacOS &&
|
||||
hasFullscreen &&
|
||||
hasLeadingOrCanPop
|
||||
? 65
|
||||
: 0,
|
||||
),
|
||||
sliver: SliverAppBar(
|
||||
leading: widget.leading,
|
||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||
actions: [
|
||||
...?widget.actions,
|
||||
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
|
||||
],
|
||||
backgroundColor: widget.backgroundColor,
|
||||
foregroundColor: widget.foregroundColor,
|
||||
actionsIconTheme: widget.actionsIconTheme,
|
||||
centerTitle: widget.centerTitle,
|
||||
titleSpacing: widget.titleSpacing,
|
||||
leadingWidth: widget.leadingWidth,
|
||||
toolbarTextStyle: widget.toolbarTextStyle,
|
||||
titleTextStyle: widget.titleTextStyle,
|
||||
title: widget.title,
|
||||
pinned: widget.pinned,
|
||||
floating: widget.floating,
|
||||
snap: widget.snap,
|
||||
stretch: widget.stretch,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
|
||||
final hasLeadingOrCanPop =
|
||||
@ -107,9 +182,9 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||
class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
final Color? foregroundColor;
|
||||
const WindowTitleBarButtons({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.foregroundColor,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -277,14 +352,13 @@ class WindowButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
WindowButton(
|
||||
{Key? key,
|
||||
{super.key,
|
||||
WindowButtonColors? colors,
|
||||
this.builder,
|
||||
@required this.iconBuilder,
|
||||
this.padding,
|
||||
this.onPressed,
|
||||
this.animate = false})
|
||||
: super(key: key) {
|
||||
this.animate = false}) {
|
||||
this.colors = colors ?? _defaultButtonColors;
|
||||
}
|
||||
|
||||
@ -350,49 +424,30 @@ class WindowButton extends StatelessWidget {
|
||||
|
||||
class MinimizeWindowButton extends WindowButton {
|
||||
MinimizeWindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
VoidCallback? onPressed,
|
||||
bool? animate})
|
||||
{super.key, super.colors, super.onPressed, bool? animate})
|
||||
: super(
|
||||
key: key,
|
||||
colors: colors,
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
MinimizeIcon(color: buttonContext.iconColor),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
class MaximizeWindowButton extends WindowButton {
|
||||
MaximizeWindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
VoidCallback? onPressed,
|
||||
bool? animate})
|
||||
{super.key, super.colors, super.onPressed, bool? animate})
|
||||
: super(
|
||||
key: key,
|
||||
colors: colors,
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
MaximizeIcon(color: buttonContext.iconColor),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
class RestoreWindowButton extends WindowButton {
|
||||
RestoreWindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
VoidCallback? onPressed,
|
||||
bool? animate})
|
||||
RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
|
||||
: super(
|
||||
key: key,
|
||||
colors: colors,
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
RestoreIcon(color: buttonContext.iconColor),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
@ -404,17 +459,12 @@ final _defaultCloseButtonColors = WindowButtonColors(
|
||||
|
||||
class CloseWindowButton extends WindowButton {
|
||||
CloseWindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
VoidCallback? onPressed,
|
||||
bool? animate})
|
||||
{super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
|
||||
: super(
|
||||
key: key,
|
||||
colors: colors ?? _defaultCloseButtonColors,
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
CloseIcon(color: buttonContext.iconColor),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
@ -423,7 +473,7 @@ class CloseWindowButton extends WindowButton {
|
||||
/// Close
|
||||
class CloseIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
const CloseIcon({Key? key, required this.color}) : super(key: key);
|
||||
const CloseIcon({super.key, required this.color});
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
@ -444,13 +494,13 @@ class CloseIcon extends StatelessWidget {
|
||||
/// Maximize
|
||||
class MaximizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
const MaximizeIcon({Key? key, required this.color}) : super(key: key);
|
||||
const MaximizeIcon({super.key, required this.color});
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
|
||||
}
|
||||
|
||||
class _MaximizePainter extends _IconPainter {
|
||||
_MaximizePainter(Color color) : super(color);
|
||||
_MaximizePainter(super.color);
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
@ -462,15 +512,15 @@ class _MaximizePainter extends _IconPainter {
|
||||
class RestoreIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
const RestoreIcon({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.color,
|
||||
}) : super(key: key);
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
|
||||
}
|
||||
|
||||
class _RestorePainter extends _IconPainter {
|
||||
_RestorePainter(Color color) : super(color);
|
||||
_RestorePainter(super.color);
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
@ -487,13 +537,13 @@ class _RestorePainter extends _IconPainter {
|
||||
/// Minimize
|
||||
class MinimizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
const MinimizeIcon({Key? key, required this.color}) : super(key: key);
|
||||
const MinimizeIcon({super.key, required this.color});
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
|
||||
}
|
||||
|
||||
class _MinimizePainter extends _IconPainter {
|
||||
_MinimizePainter(Color color) : super(color);
|
||||
_MinimizePainter(super.color);
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
@ -512,7 +562,7 @@ abstract class _IconPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
class _AlignedPaint extends StatelessWidget {
|
||||
const _AlignedPaint(this.painter, {Key? key}) : super(key: key);
|
||||
const _AlignedPaint(this.painter);
|
||||
final CustomPainter painter;
|
||||
|
||||
@override
|
||||
@ -547,8 +597,7 @@ T? _ambiguate<T>(T? value) => value;
|
||||
class MouseStateBuilder extends StatefulWidget {
|
||||
final MouseStateBuilderCB builder;
|
||||
final VoidCallback? onPressed;
|
||||
const MouseStateBuilder({Key? key, required this.builder, this.onPressed})
|
||||
: super(key: key);
|
||||
const MouseStateBuilder({super.key, required this.builder, this.onPressed});
|
||||
@override
|
||||
_MouseStateBuilderState createState() => _MouseStateBuilderState();
|
||||
}
|
||||
|
||||
@ -47,8 +47,7 @@ class ForceDraggableWidgetRenderBox extends RenderPointerListener {
|
||||
/// To make [ForceDraggableWidget] work in [Scrollable] widgets
|
||||
class PanelScrollPhysics extends ScrollPhysics {
|
||||
final PanelController controller;
|
||||
const PanelScrollPhysics({required this.controller, ScrollPhysics? parent})
|
||||
: super(parent: parent);
|
||||
const PanelScrollPhysics({required this.controller, super.parent});
|
||||
@override
|
||||
PanelScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return PanelScrollPhysics(
|
||||
|
||||
@ -146,7 +146,7 @@ class SlidingUpPanel extends StatefulWidget {
|
||||
final BoxDecoration? panelDecoration;
|
||||
|
||||
const SlidingUpPanel(
|
||||
{Key? key,
|
||||
{super.key,
|
||||
this.body,
|
||||
this.collapsed,
|
||||
this.minHeight = 100.0,
|
||||
@ -176,8 +176,7 @@ class SlidingUpPanel extends StatefulWidget {
|
||||
this.panelBuilder})
|
||||
: assert(panelBuilder != null),
|
||||
assert(0 <= backdropOpacity && backdropOpacity <= 1.0),
|
||||
assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0),
|
||||
super(key: key);
|
||||
assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0);
|
||||
|
||||
@override
|
||||
SlidingUpPanelState createState() => SlidingUpPanelState();
|
||||
|
||||
@ -43,8 +43,8 @@ class PlaybuttonCard extends HookWidget {
|
||||
this.onAddToQueuePressed,
|
||||
this.onTap,
|
||||
this.isOwner = false,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -5,7 +5,7 @@ import 'package:gap/gap.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class ShimmerLyrics extends HookWidget {
|
||||
const ShimmerLyrics({Key? key}) : super(key: key);
|
||||
const ShimmerLyrics({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -11,8 +11,8 @@ class SortTracksDropdown extends StatelessWidget {
|
||||
const SortTracksDropdown({
|
||||
this.onChanged,
|
||||
this.value,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -5,7 +5,7 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
|
||||
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
|
||||
final List<Widget> tabs;
|
||||
const ThemedButtonsTabBar({Key? key, required this.tabs}) : super(key: key);
|
||||
const ThemedButtonsTabBar({super.key, required this.tabs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@ -16,17 +15,18 @@ import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
|
||||
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
|
||||
import 'package:spotube/components/shared/heart_button.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/services/mutations/mutations.dart';
|
||||
import 'package:spotube/services/queries/search.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
enum TrackOptionValue {
|
||||
@ -53,13 +53,13 @@ class TrackOptions extends HookConsumerWidget {
|
||||
final ObjectRef<ValueChanged<RelativeRect>?>? showMenuCbRef;
|
||||
final Widget? icon;
|
||||
const TrackOptions({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.track,
|
||||
this.showMenuCbRef,
|
||||
this.userPlaylist = false,
|
||||
this.playlistId,
|
||||
this.icon,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
void actionShare(BuildContext context, Track track) {
|
||||
final data = "https://open.spotify.com/track/${track.id}";
|
||||
@ -99,21 +99,10 @@ class TrackOptions extends HookConsumerWidget {
|
||||
final playlist = ref.read(ProxyPlaylistNotifier.provider);
|
||||
final spotify = ref.read(spotifyProvider);
|
||||
final query = "${track.name} Radio";
|
||||
final pages = await QueryClient.of(context)
|
||||
.fetchInfiniteQueryJob<List<Page>, dynamic, int, SearchParams>(
|
||||
job: SearchQueries.queryJob(query),
|
||||
args: (
|
||||
spotify: spotify,
|
||||
searchType: SearchType.playlist,
|
||||
query: query,
|
||||
),
|
||||
) ??
|
||||
[];
|
||||
final pages =
|
||||
await spotify.search.get(query, types: [SearchType.playlist]).first();
|
||||
|
||||
final radios = pages
|
||||
.expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
|
||||
.toList()
|
||||
.cast<PlaylistSimple>();
|
||||
final radios = pages.map((e) => e.items).toList().cast<PlaylistSimple>();
|
||||
|
||||
final artists = track.artists!.map((e) => e.name);
|
||||
|
||||
@ -176,6 +165,7 @@ class TrackOptions extends HookConsumerWidget {
|
||||
ref.watch(downloadManagerProvider);
|
||||
final downloadManager = ref.watch(downloadManagerProvider.notifier);
|
||||
final blacklist = ref.watch(BlackListNotifier.provider);
|
||||
final me = ref.watch(meProvider);
|
||||
|
||||
final favorites = useTrackToggleLike(track, ref);
|
||||
|
||||
@ -190,10 +180,8 @@ class TrackOptions extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final removingTrack = useState<String?>(null);
|
||||
final removeTrack = useMutations.playlist.removeTrackOf(
|
||||
ref,
|
||||
playlistId ?? "",
|
||||
);
|
||||
final favoritePlaylistsNotifier =
|
||||
ref.watch(favoritePlaylistsProvider.notifier);
|
||||
|
||||
final isInQueue = useMemoized(() {
|
||||
if (playlist.activeTrack == null) return false;
|
||||
@ -220,7 +208,7 @@ class TrackOptions extends HookConsumerWidget {
|
||||
break;
|
||||
case TrackOptionValue.delete:
|
||||
await File((track as LocalTrack).path).delete();
|
||||
ref.refresh(localTracksProvider);
|
||||
ref.invalidate(localTracksProvider);
|
||||
break;
|
||||
case TrackOptionValue.addToQueue:
|
||||
await playback.addTrack(track);
|
||||
@ -257,14 +245,15 @@ class TrackOptions extends HookConsumerWidget {
|
||||
);
|
||||
break;
|
||||
case TrackOptionValue.favorite:
|
||||
favorites.toggleTrackLike.mutate(favorites.isLiked);
|
||||
favorites.toggleTrackLike(track);
|
||||
break;
|
||||
case TrackOptionValue.addToPlaylist:
|
||||
actionAddToPlaylist(context, track);
|
||||
break;
|
||||
case TrackOptionValue.removeFromPlaylist:
|
||||
removingTrack.value = track.uri;
|
||||
removeTrack.mutate(track.uri!);
|
||||
favoritePlaylistsNotifier
|
||||
.removeTracks(playlistId ?? "", [track.id!]);
|
||||
break;
|
||||
case TrackOptionValue.blacklist:
|
||||
if (isBlackListed) {
|
||||
@ -307,8 +296,8 @@ class TrackOptions extends HookConsumerWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: UniversalImage(
|
||||
path: TypeConversionUtils.image_X_UrlString(track.album!.images,
|
||||
placeholder: ImagePlaceholder.albumArt),
|
||||
path: track.album!.images
|
||||
.asUrlString(placeholder: ImagePlaceholder.albumArt),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
@ -321,14 +310,12 @@ class TrackOptions extends HookConsumerWidget {
|
||||
),
|
||||
subtitle: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TypeConversionUtils.artists_X_ClickableArtists(
|
||||
track.artists!,
|
||||
),
|
||||
child: ArtistLink(artists: track.artists!),
|
||||
),
|
||||
),
|
||||
],
|
||||
children: switch (track.runtimeType) {
|
||||
LocalTrack => [
|
||||
LocalTrack() => [
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.delete,
|
||||
leading: const Icon(SpotubeIcons.trash),
|
||||
@ -361,7 +348,7 @@ class TrackOptions extends HookConsumerWidget {
|
||||
leading: const Icon(SpotubeIcons.queueRemove),
|
||||
title: Text(context.l10n.remove_from_queue),
|
||||
),
|
||||
if (favorites.me.hasData)
|
||||
if (me.asData?.value != null)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.favorite,
|
||||
leading: favorites.isLiked
|
||||
@ -391,10 +378,7 @@ class TrackOptions extends HookConsumerWidget {
|
||||
if (userPlaylist && auth != null)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.removeFromPlaylist,
|
||||
leading: (removeTrack.isMutating || !removeTrack.hasData) &&
|
||||
removingTrack.value == track.uri
|
||||
? const CircularProgressIndicator()
|
||||
: const Icon(SpotubeIcons.removeFilled),
|
||||
leading: const Icon(SpotubeIcons.removeFilled),
|
||||
title: Text(context.l10n.remove_from_playlist),
|
||||
),
|
||||
PopSheetEntry(
|
||||
|
||||
@ -9,14 +9,16 @@ import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/hover_builder.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/components/shared/links/link_text.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_options.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
|
||||
class TrackTile extends HookConsumerWidget {
|
||||
/// [index] will not be shown if null
|
||||
@ -28,25 +30,26 @@ class TrackTile extends HookConsumerWidget {
|
||||
final VoidCallback? onLongPress;
|
||||
final bool userPlaylist;
|
||||
final String? playlistId;
|
||||
final ProxyPlaylist playlist;
|
||||
|
||||
final List<Widget>? leadingActions;
|
||||
|
||||
const TrackTile({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.index,
|
||||
required this.track,
|
||||
this.selected = false,
|
||||
required this.playlist,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onChanged,
|
||||
this.userPlaylist = false,
|
||||
this.playlistId,
|
||||
this.leadingActions,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final blacklist = ref.watch(BlackListNotifier.provider);
|
||||
@ -63,10 +66,10 @@ class TrackTile extends HookConsumerWidget {
|
||||
|
||||
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
|
||||
|
||||
final isPlaying = track.id == playlist.activeTrack?.id;
|
||||
|
||||
final isLoading = useState(false);
|
||||
|
||||
final isPlaying = playlist.activeTrack?.id == track.id;
|
||||
|
||||
final isSelected = isPlaying || isLoading.value;
|
||||
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
@ -135,8 +138,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: UniversalImage(
|
||||
path: TypeConversionUtils.image_X_UrlString(
|
||||
track.album?.images,
|
||||
path: (track.album?.images).asUrlString(
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
@ -230,16 +232,12 @@ class TrackTile extends HookConsumerWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: track is LocalTrack
|
||||
? Text(
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
track.artists ?? [],
|
||||
),
|
||||
track.artists?.asString() ?? '',
|
||||
)
|
||||
: ClipRect(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 40),
|
||||
child: TypeConversionUtils.artists_X_ClickableArtists(
|
||||
track.artists ?? [],
|
||||
),
|
||||
child: ArtistLink(artists: track.artists ?? []),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -8,18 +8,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/fake.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class TrackViewBodySection extends HookConsumerWidget {
|
||||
const TrackViewBodySection({Key? key}) : super(key: key);
|
||||
const TrackViewBodySection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
loadingBuilder: (context) => Skeletonizer(
|
||||
enabled: true,
|
||||
child: TrackTile(
|
||||
playlist: playlist,
|
||||
track: FakeData.track,
|
||||
index: 0,
|
||||
),
|
||||
@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
child: Column(
|
||||
children: List.generate(
|
||||
10,
|
||||
(index) => TrackTile(track: FakeData.track, index: index),
|
||||
(index) => TrackTile(
|
||||
track: FakeData.track,
|
||||
index: index,
|
||||
playlist: playlist,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final track = tracks[index];
|
||||
return TrackTile(
|
||||
playlist: playlist,
|
||||
track: track,
|
||||
index: index,
|
||||
selected: trackViewState.selectedTrackIds.contains(track.id!),
|
||||
@ -125,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActive || playlist.tracks.contains(track)) {
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
final isRemoteDevice =
|
||||
await showSelectDeviceDialog(context, ref);
|
||||
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
final remoteQueue = ref.read(queueProvider);
|
||||
if (remoteQueue.collections.contains(props.collectionId) ||
|
||||
remoteQueue.tracks.any((s) => s.id == track.id)) {
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
} else {
|
||||
final tracks = await props.pagination.onFetchAll();
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: tracks,
|
||||
collectionId: props.collectionId,
|
||||
initialIndex: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final tracks = await props.pagination.onFetchAll();
|
||||
await playlistNotifier.load(
|
||||
tracks,
|
||||
initialIndex: index,
|
||||
autoPlay: true,
|
||||
);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
if (isActive || playlist.tracks.contains(track)) {
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
} else {
|
||||
final tracks = await props.pagination.onFetchAll();
|
||||
await playlistNotifier.load(
|
||||
tracks,
|
||||
initialIndex: index,
|
||||
autoPlay: true,
|
||||
);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -13,10 +13,10 @@ class TrackViewBodyHeaders extends HookConsumerWidget {
|
||||
final FocusNode searchFocus;
|
||||
|
||||
const TrackViewBodyHeaders({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.isFiltering,
|
||||
required this.searchFocus,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -13,7 +13,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
|
||||
class TrackViewBodyOptions extends HookConsumerWidget {
|
||||
const TrackViewBodyOptions({Key? key}) : super(key: key);
|
||||
const TrackViewBodyOptions({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
bool useIsUserPlaylist(WidgetRef ref, String playlistId) {
|
||||
final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref);
|
||||
final me = useQueries.user.me(ref);
|
||||
final userPlaylistsQuery = ref.watch(favoritePlaylistsProvider);
|
||||
final me = ref.watch(meProvider);
|
||||
|
||||
return useMemoized(
|
||||
() =>
|
||||
userPlaylistsQuery.data?.any((e) =>
|
||||
userPlaylistsQuery.asData?.value.items.any((e) =>
|
||||
e.id == playlistId &&
|
||||
me.data != null &&
|
||||
e.owner?.id == me.data?.id) ??
|
||||
me.asData?.value != null &&
|
||||
e.owner?.id == me.asData?.value.id) ??
|
||||
false,
|
||||
[userPlaylistsQuery.data, playlistId, me.data],
|
||||
[userPlaylistsQuery.asData?.value, playlistId, me.asData?.value],
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/hooks/utils/use_palette_color.dart';
|
||||
|
||||
class TrackViewFlexHeader extends HookConsumerWidget {
|
||||
const TrackViewFlexHeader({Key? key}) : super(key: key);
|
||||
const TrackViewFlexHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -12,7 +12,7 @@ import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
|
||||
class TrackViewHeaderActions extends HookConsumerWidget {
|
||||
const TrackViewHeaderActions({Key? key}) : super(key: key);
|
||||
const TrackViewHeaderActions({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -6,8 +6,11 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
|
||||
@ -15,10 +18,10 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
final PaletteColor color;
|
||||
final bool compact;
|
||||
const TrackViewHeaderButtons({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.color,
|
||||
this.compact = false,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -43,13 +46,25 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
|
||||
final allTracks = await props.pagination.onFetchAll();
|
||||
|
||||
await playlistNotifier.load(
|
||||
allTracks,
|
||||
autoPlay: true,
|
||||
initialIndex: Random().nextInt(allTracks.length),
|
||||
);
|
||||
await audioPlayer.setShuffle(true);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: allTracks,
|
||||
collectionId: props.collectionId,
|
||||
initialIndex: Random().nextInt(allTracks.length)),
|
||||
);
|
||||
await remotePlayback.setShuffle(true);
|
||||
} else {
|
||||
await playlistNotifier.load(
|
||||
allTracks,
|
||||
autoPlay: true,
|
||||
initialIndex: Random().nextInt(allTracks.length),
|
||||
);
|
||||
await audioPlayer.setShuffle(true);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
|
||||
final allTracks = await props.pagination.onFetchAll();
|
||||
|
||||
await playlistNotifier.load(allTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: allTracks,
|
||||
collectionId: props.collectionId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(allTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/track_view_b
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||
|
||||
class TrackView extends HookConsumerWidget {
|
||||
const TrackView({Key? key}) : super(key: key);
|
||||
const TrackView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
@ -19,19 +18,6 @@ class PaginationProps {
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
factory PaginationProps.fromQuery(
|
||||
InfiniteQuery<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
|
||||
operator ==(Object other) {
|
||||
return other is PaginationProps &&
|
||||
|
||||
@ -11,12 +11,12 @@ class Waypoint extends HookWidget {
|
||||
final bool isGrid;
|
||||
|
||||
const Waypoint({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.isGrid = false,
|
||||
this.onTouchEdge,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
extension AlbumJson on AlbumSimple {
|
||||
extension AlbumExtensions on AlbumSimple {
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"albumType": albumType?.name,
|
||||
@ -15,4 +15,22 @@ extension AlbumJson on AlbumSimple {
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
Album toAlbum() {
|
||||
Album album = Album();
|
||||
album.albumType = albumType;
|
||||
album.artists = artists;
|
||||
album.availableMarkets = availableMarkets;
|
||||
album.externalUrls = externalUrls;
|
||||
album.href = href;
|
||||
album.id = id;
|
||||
album.images = images;
|
||||
album.name = name;
|
||||
album.releaseDate = releaseDate;
|
||||
album.releaseDatePrecision = releaseDatePrecision;
|
||||
album.tracks = tracks;
|
||||
album.type = type;
|
||||
album.uri = uri;
|
||||
return album;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,3 +11,9 @@ extension ArtistJson on ArtistSimple {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension ArtistExtension on List<ArtistSimple> {
|
||||
String asString() {
|
||||
return map((e) => e.name?.replaceAll(",", " ")).join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
34
lib/extensions/image.dart
Normal file
34
lib/extensions/image.dart
Normal file
@ -0,0 +1,34 @@
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
enum ImagePlaceholder {
|
||||
albumArt,
|
||||
artist,
|
||||
collection,
|
||||
online,
|
||||
}
|
||||
|
||||
extension SpotifyImageExtensions on List<Image>? {
|
||||
String asUrlString({
|
||||
int index = 1,
|
||||
required ImagePlaceholder placeholder,
|
||||
}) {
|
||||
final String placeholderUrl = {
|
||||
ImagePlaceholder.albumArt: Assets.albumPlaceholder.path,
|
||||
ImagePlaceholder.artist: Assets.userPlaceholder.path,
|
||||
ImagePlaceholder.collection: Assets.placeholder.path,
|
||||
ImagePlaceholder.online:
|
||||
"https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png",
|
||||
}[placeholder]!;
|
||||
|
||||
final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!));
|
||||
|
||||
return sortedImage != null && sortedImage.isNotEmpty
|
||||
? sortedImage[
|
||||
index > sortedImage.length - 1 ? sortedImage.length - 1 : index]
|
||||
.url!
|
||||
: placeholderUrl;
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
extension FetchAllTracks on InfiniteQuery<List<Track>, dynamic, int> {
|
||||
Future<List<Track>> fetchAllTracks({
|
||||
required Future<List<Track>> Function() getAllTracks,
|
||||
}) async {
|
||||
if (pages.isNotEmpty && !hasNextPage) {
|
||||
return pages.expand((page) => page).toList();
|
||||
}
|
||||
final tracks = await getAllTracks();
|
||||
|
||||
final numOfPages = (tracks.length / 20).round();
|
||||
|
||||
final Map<int, List<Track>> pagedTracks = {};
|
||||
|
||||
for (var i = 0; i < numOfPages; i++) {
|
||||
if (i == numOfPages - 1) {
|
||||
final pageTracks = tracks.sublist(i * 20);
|
||||
pagedTracks[i] = pageTracks;
|
||||
break;
|
||||
}
|
||||
|
||||
final pageTracks = tracks.sublist(i * 20, (i + 1) * 20);
|
||||
pagedTracks[i] = pageTracks;
|
||||
}
|
||||
|
||||
for (final group in pagedTracks.entries) {
|
||||
setPageData(group.key, group.value);
|
||||
}
|
||||
|
||||
return tracks.toList();
|
||||
}
|
||||
}
|
||||
@ -9,3 +9,9 @@ extension UnescapeHtml on String {
|
||||
extension NullableUnescapeHtml on String? {
|
||||
String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!);
|
||||
}
|
||||
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return "${this[0].toUpperCase()}${substring(1)}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,46 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:metadata_god/metadata_god.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/extensions/album_simple.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
|
||||
extension TrackJson on Track {
|
||||
extension TrackExtensions on Track {
|
||||
Track fromFile(
|
||||
File file, {
|
||||
Metadata? metadata,
|
||||
String? art,
|
||||
}) {
|
||||
album = Album()
|
||||
..name = metadata?.album ?? "Unknown"
|
||||
..images = [if (art != null) Image()..url = art]
|
||||
..genres = [if (metadata?.genre != null) metadata!.genre!]
|
||||
..artists = [
|
||||
Artist()
|
||||
..name = metadata?.albumArtist ?? "Unknown"
|
||||
..id = metadata?.albumArtist ?? "Unknown"
|
||||
..type = "artist",
|
||||
]
|
||||
..id = metadata?.album
|
||||
..releaseDate = metadata?.year?.toString();
|
||||
artists = [
|
||||
Artist()
|
||||
..name = metadata?.artist ?? "Unknown"
|
||||
..id = metadata?.artist ?? "Unknown"
|
||||
];
|
||||
|
||||
id = metadata?.title ?? basenameWithoutExtension(file.path);
|
||||
name = metadata?.title ?? basenameWithoutExtension(file.path);
|
||||
type = "track";
|
||||
uri = file.path;
|
||||
durationMs = (metadata?.durationMs?.toInt() ?? 0);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return TrackJson.trackToJson(this);
|
||||
return TrackExtensions.trackToJson(this);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> trackToJson(Track track) {
|
||||
@ -30,3 +66,27 @@ extension TrackJson on Track {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension TrackSimpleExtensions on TrackSimple {
|
||||
Track asTrack(AlbumSimple album) {
|
||||
Track track = Track();
|
||||
track.name = name;
|
||||
track.album = album;
|
||||
track.artists = artists;
|
||||
track.availableMarkets = availableMarkets;
|
||||
track.discNumber = discNumber;
|
||||
track.durationMs = durationMs;
|
||||
track.explicit = explicit;
|
||||
track.externalUrls = externalUrls;
|
||||
track.href = href;
|
||||
track.id = id;
|
||||
track.isPlayable = isPlayable;
|
||||
track.linkedFrom = linkedFrom;
|
||||
track.name = name;
|
||||
track.previewUrl = previewUrl;
|
||||
track.trackNumber = trackNumber;
|
||||
track.type = type;
|
||||
track.uri = uri;
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user