Merge branch 'dev' into android-force-hfr

This commit is contained in:
Muhammad Brian Abdillah 2023-12-04 22:43:19 +07:00 committed by GitHub
commit e3cb8acf5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
133 changed files with 4721 additions and 3324 deletions

View File

@ -82,8 +82,6 @@ jobs:
name: Generate .env file name: Generate .env file
command: | command: |
echo "SPOTIFY_SECRETS=${SPOTIFY_SECRETS}" >> .env echo "SPOTIFY_SECRETS=${SPOTIFY_SECRETS}" >> .env
echo "SUPABASE_URL=${SUPABASE_URL}" >> .env
echo "SUPABASE_API_KEY=${SUPABASE_API_KEY}" >> .env
- run: - run:
name: Replace Version in files name: Replace Version in files

View File

@ -1,6 +1,3 @@
SUPABASE_URL=
SUPABASE_API_KEY=
# The format: # The format:
# SPOTIFY_SECRETS=clintId1:clientSecret1,clientId2:clientSecret2 # SPOTIFY_SECRETS=clintId1:clientSecret1,clientId2:clientSecret2
SPOTIFY_SECRETS= SPOTIFY_SECRETS=

View File

@ -1,4 +1,4 @@
{ {
"flutterSdkVersion": "3.10.0", "flutterSdkVersion": "3.16.0",
"flavors": {} "flavors": {}
} }

View File

@ -4,7 +4,7 @@ on:
inputs: inputs:
version: version:
description: Version to release (x.x.x) description: Version to release (x.x.x)
default: 3.2.0 default: 3.3.0
required: true required: true
channel: channel:
type: choice type: choice
@ -26,7 +26,7 @@ on:
default: true default: true
env: env:
FLUTTER_VERSION: '3.13.2' FLUTTER_VERSION: '3.16.0'
jobs: jobs:
windows: windows:
@ -163,7 +163,6 @@ jobs:
dart pub global activate flutter_distributor dart pub global activate flutter_distributor
alias dpkg-deb="dpkg-deb --Zxz" alias dpkg-deb="dpkg-deb --Zxz"
flutter_distributor package --platform=linux --targets=deb flutter_distributor package --platform=linux --targets=deb
flutter_distributor package --platform=linux --targets=appimage
flutter_distributor package --platform=linux --targets=rpm flutter_distributor package --platform=linux --targets=rpm
- name: Create tar.xz (stable) - name: Create tar.xz (stable)
@ -179,7 +178,6 @@ jobs:
mv build/spotube-linux-*-x86_64.tar.xz dist/ mv build/spotube-linux-*-x86_64.tar.xz dist/
mv dist/**/spotube-*-linux.deb dist/Spotube-linux-x86_64.deb mv dist/**/spotube-*-linux.deb dist/Spotube-linux-x86_64.deb
mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-x86_64.rpm mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-x86_64.rpm
mv dist/**/spotube-*-linux.AppImage dist/Spotube-linux-x86_64.AppImage
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
@ -187,7 +185,6 @@ jobs:
if-no-files-found: error if-no-files-found: error
name: Spotube-Release-Binaries name: Spotube-Release-Binaries
path: | path: |
dist/Spotube-linux-x86_64.AppImage
dist/Spotube-linux-x86_64.deb dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz
@ -319,6 +316,7 @@ jobs:
- name: Package Macos App - name: Package Macos App
run: | run: |
python3 -m pip install setuptools
npm install -g appdmg npm install -g appdmg
mkdir -p build/${{ env.BUILD_VERSION }} mkdir -p build/${{ env.BUILD_VERSION }}
appdmg appdmg.json build/Spotube-macos-universal.dmg appdmg appdmg.json build/Spotube-macos-universal.dmg

View File

@ -2,6 +2,45 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [3.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27)
### Features
* Add JioSaavn as audio source ([#881](https://github.com/KRTirtho/spotube/issues/881)) ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55))
* **android:** better quick scroll/drag to scroll implementation ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e))
* **artist:** modularize page and add wikipedia section ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f))
* discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb))
* **mini_player:** show/hide lyrics [#851](https://github.com/KRTirtho/spotube/issues/851) ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590))
* paginated playlist and album page ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90))
* **translations:** add Turkish translations ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966))
### Bug Fixes
* "Add () to Playlist" option not showing in favorited playlists [#904](https://github.com/KRTirtho/spotube/issues/904) ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43))
* 0:00 media duration in queue after application restart [#782](https://github.com/KRTirtho/spotube/issues/782) ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8))
* Add to Playlist Dialog memory leak [#817](https://github.com/KRTirtho/spotube/issues/817) ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d))
* **album_card:** show loading state during adding track to queue/play ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968))
* alternative track source safearea overflow [#876](https://github.com/KRTirtho/spotube/issues/876) ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d))
* android invalid download location Download not starting or not explaining error [#720](https://github.com/KRTirtho/spotube/issues/720) ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042))
* changed settings are not persisting after force stop [#821](https://github.com/KRTirtho/spotube/issues/821) ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261))
* check for unsynced lyrics and error handling for timed lyrics query ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7))
* **genres:** lag while scrolling ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469))
* infinite list disappearing for a moment everytime new page is fetched ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314))
* last track of queue keeps repeating [#718](https://github.com/KRTirtho/spotube/issues/718) ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb))
* Navigating to settings, redirects to home page [#812](https://github.com/KRTirtho/spotube/issues/812) ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f))
* new releases section flickering on scroll glitch ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895))
* **playbutton_card:** annoying animation ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012))
* scrobbling not working for first track or single track ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71))
* settings page scrollbar position ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd))
* shuffle doesn't move active track to top ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2))
* spotube doesn't exit properly, hangs in infinite loop [#768](https://github.com/KRTirtho/spotube/issues/768) ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175))
* trim login field padding ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172))
* use CustomScrollView for personalized page ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d))
* user_playlists layout, track tile index, ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb))
* **windows:** media control not working [#641](https://github.com/KRTirtho/spotube/issues/641) ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a))
## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) ## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16)

View File

@ -119,7 +119,7 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirt
Do the following: Do the following:
- Download the latest Flutter SDK (>=3.10.0) & enable desktop support - Download the latest Flutter SDK (>=3.16.0) & enable desktop support
- Install Development dependencies in linux - Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu - Debian (>=12/Bookworm)/Ubuntu
```bash ```bash

View File

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

View File

@ -1,7 +1,7 @@
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:path/path.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:pub_api_client/pub_api_client.dart'; import 'package:pub_api_client/pub_api_client.dart';
@ -33,15 +33,20 @@ void main() async {
final gitDeps = gitDepsList.map( final gitDeps = gitDepsList.map(
(d) { (d) {
final uri = Uri.parse(
d.value.url.toString().replaceAll('.git', ''),
);
return MapEntry( return MapEntry(
d.key, d.key,
join( uri.replace(
d.value.url.toString().replaceAll('.git', ''), pathSegments: [
'raw', ...uri.pathSegments,
d.value.ref ?? 'main', 'raw',
d.value.path ?? '', d.value.ref ?? 'main',
'pubspec.yaml', d.value.path ?? '',
), 'pubspec.yaml',
],
).toString(),
); );
}, },
).toList(); ).toList();
@ -55,7 +60,10 @@ void main() async {
} catch (e) { } catch (e) {
final document = parse(res.body); final document = parse(res.body);
final pre = document.querySelector('pre'); final pre = document.querySelector('pre');
if (pre == null) rethrow; if (pre == null) {
log(d.toString());
rethrow;
}
return Pubspec.parse(pre.text); return Pubspec.parse(pre.text);
} }
} }

View File

@ -5,12 +5,6 @@ part 'env.g.dart';
@Envied(obfuscate: true, requireEnvFile: true, path: ".env") @Envied(obfuscate: true, requireEnvFile: true, path: ".env")
abstract class Env { abstract class Env {
@EnviedField(varName: 'SUPABASE_URL')
static final String? supabaseUrl = _Env.supabaseUrl;
@EnviedField(varName: 'SUPABASE_API_KEY')
static final String? supabaseAnonKey = _Env.supabaseAnonKey;
@EnviedField(varName: 'SPOTIFY_SECRETS') @EnviedField(varName: 'SPOTIFY_SECRETS')
static final String rawSpotifySecrets = _Env.rawSpotifySecrets; static final String rawSpotifySecrets = _Env.rawSpotifySecrets;
@ -33,4 +27,6 @@ abstract class Env {
static bool get enableUpdateChecker => static bool get enableUpdateChecker =>
DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1";
static String discordAppId = "1176718791388975124";
} }

View File

@ -3,29 +3,29 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/playlist/liked_playlist.dart';
import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/about.dart';
import 'package:spotube/pages/settings/logs.dart'; import 'package:spotube/pages/settings/logs.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/components/shared/spotube_page_route.dart'; import 'package:spotube/components/shared/spotube_page_route.dart';
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/library/library.dart';
import 'package:spotube/pages/desktop_login/login_tutorial.dart'; import 'package:spotube/pages/desktop_login/login_tutorial.dart';
import 'package:spotube/pages/desktop_login/desktop_login.dart'; import 'package:spotube/pages/desktop_login/desktop_login.dart';
import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/root/root_app.dart';
import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart';
import '../pages/library/playlist_generate/playlist_generate_result.dart';
final rootNavigatorKey = Catcher2.navigatorKey; final rootNavigatorKey = Catcher2.navigatorKey;
final shellRouteNavigatorKey = GlobalKey<NavigatorState>(); final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
final router = GoRouter( final router = GoRouter(
@ -104,7 +104,9 @@ final router = GoRouter(
path: "/album/:id", path: "/album/:id",
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is AlbumSimple); assert(state.extra is AlbumSimple);
return SpotubePage(child: AlbumPage(state.extra as AlbumSimple)); return SpotubePage(
child: AlbumPage(album: state.extra as AlbumSimple),
);
}, },
), ),
GoRoute( GoRoute(
@ -119,7 +121,9 @@ final router = GoRouter(
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is PlaylistSimple); assert(state.extra is PlaylistSimple);
return SpotubePage( return SpotubePage(
child: PlaylistView(state.extra as PlaylistSimple), child: state.pathParameters["id"] == "user-liked-tracks"
? LikedPlaylistPage(playlist: state.extra as PlaylistSimple)
: PlaylistPage(playlist: state.extra as PlaylistSimple),
); );
}, },
), ),

View File

@ -40,6 +40,7 @@ abstract class SpotubeIcons {
static const trash = FeatherIcons.trash2; static const trash = FeatherIcons.trash2;
static const clock = FeatherIcons.clock; static const clock = FeatherIcons.clock;
static const lyrics = Icons.lyrics_rounded; static const lyrics = Icons.lyrics_rounded;
static const lyricsOff = Icons.lyrics_outlined;
static const logout = FeatherIcons.logOut; static const logout = FeatherIcons.logOut;
static const login = FeatherIcons.logIn; static const login = FeatherIcons.logIn;
static const dashboard = FeatherIcons.grid; static const dashboard = FeatherIcons.grid;
@ -106,4 +107,5 @@ abstract class SpotubeIcons {
static const eye = FeatherIcons.eye; static const eye = FeatherIcons.eye;
static const noEye = FeatherIcons.eyeOff; static const noEye = FeatherIcons.eyeOff;
static const normalize = FeatherIcons.barChart2; static const normalize = FeatherIcons.barChart2;
static const wikipedia = SimpleIcons.wikipedia;
} }

View File

@ -4,9 +4,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/queries/album.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -15,7 +18,7 @@ extension FormattedAlbumType on AlbumType {
} }
class AlbumCard extends HookConsumerWidget { class AlbumCard extends HookConsumerWidget {
final Album album; final AlbumSimple album;
const AlbumCard( const AlbumCard(
this.album, { this.album, {
Key? key, Key? key,
@ -27,7 +30,9 @@ class AlbumCard extends HookConsumerWidget {
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final queryClient = useQueryClient(); final queryClient = useQueryClient();
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
() => playlist.containsCollection(album.id!), () => playlist.containsCollection(album.id!),
[playlist, album.id], [playlist, album.id],
@ -36,6 +41,34 @@ class AlbumCard extends HookConsumerWidget {
final updating = useState(false); final updating = useState(false);
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
Future<List<Track>> fetchAllTrack() async {
if (album.tracks != null && album.tracks!.isNotEmpty) {
return album.tracks!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList();
}
final job = AlbumQueries.tracksOfJob(album.id!);
final query = queryClient.createInfiniteQuery(
job.queryKey,
(page) => job.task(page, (spotify: spotify, album: album)),
initialPage: 0,
nextPage: job.nextPage,
);
return await query.fetchAllTracks(
getAllTracks: () async {
final res = await spotify.albums.tracks(album.id!).all();
return res
.map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
.toList();
},
);
}
return PlaybuttonCard( return PlaybuttonCard(
imageUrl: TypeConversionUtils.image_X_UrlString( imageUrl: TypeConversionUtils.image_X_UrlString(
album.images, album.images,
@ -54,20 +87,15 @@ class AlbumCard extends HookConsumerWidget {
onPlaybuttonPressed: () async { onPlaybuttonPressed: () async {
updating.value = true; updating.value = true;
try { try {
if (isPlaylistPlaying && playing) { if (isPlaylistPlaying) {
return audioPlayer.pause(); return playing ? audioPlayer.pause() : audioPlayer.resume();
} else if (isPlaylistPlaying && !playing) {
return audioPlayer.resume();
} }
await playlistNotifier.load( final fetchedTracks = await fetchAllTrack();
album.tracks
?.map((e) => if (fetchedTracks.isEmpty) return;
TypeConversionUtils.simpleTrack_X_Track(e, album))
.toList() ?? await playlistNotifier.load(fetchedTracks, autoPlay: true);
[],
autoPlay: true,
);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
} finally { } finally {
updating.value = false; updating.value = false;
@ -80,28 +108,16 @@ class AlbumCard extends HookConsumerWidget {
updating.value = true; updating.value = true;
try { try {
final fetchedTracks = final fetchedTracks = await fetchAllTrack();
await queryClient.fetchQuery<List<TrackSimple>, SpotifyApi>(
"album-tracks/${album.id}",
() {
return spotify.albums
.tracks(album.id!)
.all()
.then((value) => value.toList());
},
).then(
(tracks) => tracks
?.map(
(e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
.toList(),
);
if (fetchedTracks == null || fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( final snackbar = SnackBar(
content: Text("Added ${album.tracks?.length} tracks to queue"), content: Text(
context.l10n.added_to_queue(fetchedTracks.length),
),
action: SnackBarAction( action: SnackBarAction(
label: "Undo", label: "Undo",
onPressed: () { onPressed: () {
@ -110,7 +126,8 @@ class AlbumCard extends HookConsumerWidget {
}, },
), ),
); );
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
scaffoldMessenger?.showSnackBar(snackbar);
} }
} finally { } finally {
updating.value = false; updating.value = false;

View File

@ -5,9 +5,9 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/download_manager/download_status.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadItem extends HookConsumerWidget { class DownloadItem extends HookConsumerWidget {
@ -24,25 +24,25 @@ class DownloadItem extends HookConsumerWidget {
final taskStatus = useState<DownloadStatus?>(null); final taskStatus = useState<DownloadStatus?>(null);
useEffect(() { useEffect(() {
if (track is! SpotubeTrack) return null; if (track is! SourcedTrack) return null;
final notifier = downloadManager.getStatusNotifier(track as SpotubeTrack); final notifier = downloadManager.getStatusNotifier(track as SourcedTrack);
taskStatus.value = notifier?.value; taskStatus.value = notifier?.value;
listener() {
void listener() {
taskStatus.value = notifier?.value; taskStatus.value = notifier?.value;
} }
downloadManager notifier?.addListener(listener);
.getStatusNotifier(track as SpotubeTrack)
?.addListener(listener);
return () { return () {
downloadManager notifier?.removeListener(listener);
.getStatusNotifier(track as SpotubeTrack)
?.removeListener(listener);
}; };
}, [track]); }, [track]);
final isQueryingSourceInfo =
taskStatus.value == null || track is! SourcedTrack;
return ListTile( return ListTile(
leading: Padding( leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5), padding: const EdgeInsets.symmetric(horizontal: 5),
@ -63,7 +63,7 @@ class DownloadItem extends HookConsumerWidget {
track.artists ?? <Artist>[], track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start, mainAxisAlignment: WrapAlignment.start,
), ),
trailing: taskStatus.value == null || track is! SpotubeTrack trailing: isQueryingSourceInfo
? Text( ? Text(
context.l10n.querying_info, context.l10n.querying_info,
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
@ -72,7 +72,7 @@ class DownloadItem extends HookConsumerWidget {
DownloadStatus.downloading => HookBuilder(builder: (context) { DownloadStatus.downloading => HookBuilder(builder: (context) {
final taskProgress = useListenable(useMemoized( final taskProgress = useListenable(useMemoized(
() => downloadManager () => downloadManager
.getProgressNotifier(track as SpotubeTrack), .getProgressNotifier(track as SourcedTrack),
[track], [track],
)); ));
return SizedBox( return SizedBox(
@ -86,13 +86,13 @@ class DownloadItem extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(SpotubeIcons.pause), icon: const Icon(SpotubeIcons.pause),
onPressed: () { onPressed: () {
downloadManager.pause(track as SpotubeTrack); downloadManager.pause(track as SourcedTrack);
}), }),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton( IconButton(
icon: const Icon(SpotubeIcons.close), icon: const Icon(SpotubeIcons.close),
onPressed: () { onPressed: () {
downloadManager.cancel(track as SpotubeTrack); downloadManager.cancel(track as SourcedTrack);
}), }),
], ],
), ),
@ -104,13 +104,13 @@ class DownloadItem extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(SpotubeIcons.play), icon: const Icon(SpotubeIcons.play),
onPressed: () { onPressed: () {
downloadManager.resume(track as SpotubeTrack); downloadManager.resume(track as SourcedTrack);
}), }),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton( IconButton(
icon: const Icon(SpotubeIcons.close), icon: const Icon(SpotubeIcons.close),
onPressed: () { onPressed: () {
downloadManager.cancel(track as SpotubeTrack); downloadManager.cancel(track as SourcedTrack);
}) })
], ],
), ),
@ -126,7 +126,7 @@ class DownloadItem extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(SpotubeIcons.refresh), icon: const Icon(SpotubeIcons.refresh),
onPressed: () { onPressed: () {
downloadManager.retry(track as SpotubeTrack); downloadManager.retry(track as SourcedTrack);
}, },
), ),
], ],
@ -137,7 +137,7 @@ class DownloadItem extends HookConsumerWidget {
DownloadStatus.queued => IconButton( DownloadStatus.queued => IconButton(
icon: const Icon(SpotubeIcons.close), icon: const Icon(SpotubeIcons.close),
onPressed: () { onPressed: () {
downloadManager.removeFromQueue(track as SpotubeTrack); downloadManager.removeFromQueue(track as SourcedTrack);
}), }),
}, },
); );

View File

@ -18,7 +18,7 @@ import 'package:spotube/components/shared/expandable_search/expandable_search.da
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
@ -199,7 +199,8 @@ class UserLocalTracks extends HookConsumerWidget {
), ),
const Spacer(), const Spacer(),
ExpandableSearchButton( ExpandableSearchButton(
isFiltering: isFiltering, isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus, searchFocus: searchFocus,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -222,7 +223,8 @@ class UserLocalTracks extends HookConsumerWidget {
ExpandableSearchField( ExpandableSearchField(
searchController: searchController, searchController: searchController,
searchFocus: searchFocus, searchFocus: searchFocus,
isFiltering: isFiltering, isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
), ),
trackSnapshot.when( trackSnapshot.when(
data: (tracks) { data: (tracks) {
@ -284,7 +286,7 @@ class UserLocalTracks extends HookConsumerWidget {
); );
}, },
loading: () => loading: () =>
const Expanded(child: ShimmerTrackTile(noSliver: true)), const Expanded(child: ShimmerTrackTileGroup(noSliver: true)),
error: (error, stackTrace) => error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()), Text(error.toString() + stackTrace.toString()),
) )

View File

@ -14,6 +14,7 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
@ -120,31 +121,33 @@ class UserPlaylists extends HookConsumerWidget {
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: SizedBox(height: 10), child: SizedBox(height: 10),
), ),
SliverGrid.builder( SliverLayoutBuilder(builder: (context, constrains) {
itemCount: playlists.length + 1, return SliverGrid.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( itemCount: playlists.length + 1,
maxCrossAxisExtent: 200, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
mainAxisExtent: DesktopTools.platform.isMobile ? 225 : 250, maxCrossAxisExtent: 200,
crossAxisSpacing: 8, mainAxisExtent: constrains.smAndDown ? 225 : 250,
mainAxisSpacing: 8, crossAxisSpacing: 8,
), mainAxisSpacing: 8,
itemBuilder: (context, index) { ),
if (index == playlists.length) { itemBuilder: (context, index) {
if (!playlistsQuery.hasNextPage) { if (index == playlists.length) {
return const SizedBox.shrink(); if (!playlistsQuery.hasNextPage) {
return const SizedBox.shrink();
}
return Waypoint(
controller: controller,
isGrid: true,
onTouchEdge: playlistsQuery.fetchNext,
child: const ShimmerPlaybuttonCard(count: 1),
);
} }
return Waypoint( return PlaylistCard(playlists[index]);
controller: controller, },
isGrid: true, );
onTouchEdge: playlistsQuery.fetchNext, })
child: const ShimmerPlaybuttonCard(count: 1),
);
}
return PlaylistCard(playlists[index]);
},
)
], ],
), ),
), ),

View File

@ -11,7 +11,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';

View File

@ -1,5 +1,6 @@
import 'dart:ui'; import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -12,13 +13,13 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/hooks/utils/use_debounce.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/services/sourced_track/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -35,7 +36,6 @@ class SiblingTracksSheet extends HookConsumerWidget {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final preferences = ref.watch(userPreferencesProvider); final preferences = ref.watch(userPreferencesProvider);
final youtube = ref.watch(youtubeProvider);
final isSearching = useState(false); final isSearching = useState(false);
final searchMode = useState(preferences.searchMode); final searchMode = useState(preferences.searchMode);
@ -61,18 +61,31 @@ class SiblingTracksSheet extends HookConsumerWidget {
final searchRequest = useMemoized(() async { final searchRequest = useMemoized(() async {
if (searchTerm.trim().isEmpty) { if (searchTerm.trim().isEmpty) {
return <YoutubeVideoInfo>[]; return <SourceInfo>[];
} }
return youtube.search(searchTerm.trim()); final results = await youtubeClient.search.search(searchTerm.trim());
return await Future.wait(
results.map(YoutubeVideoInfo.fromVideo).mapIndexed((i, video) async {
final siblingType = await YoutubeSourcedTrack.toSiblingType(i, video);
return siblingType.info;
}),
);
}, [ }, [
searchTerm, searchTerm,
searchMode.value, searchMode.value,
]); ]);
final siblings = playlist.isFetching == false final siblings = useMemoized(
? (playlist.activeTrack as SpotubeTrack).siblings () => playlist.isFetching == false
: <YoutubeVideoInfo>[]; ? [
(playlist.activeTrack as SourcedTrack).sourceInfo,
...(playlist.activeTrack as SourcedTrack).siblings,
]
: <SourceInfo>[],
[playlist.isFetching, playlist.activeTrack],
);
final borderRadius = floating final borderRadius = floating
? BorderRadius.circular(10) ? BorderRadius.circular(10)
@ -82,21 +95,21 @@ class SiblingTracksSheet extends HookConsumerWidget {
); );
useEffect(() { useEffect(() {
if (playlist.activeTrack is SpotubeTrack && if (playlist.activeTrack is SourcedTrack &&
(playlist.activeTrack as SpotubeTrack).siblings.isEmpty) { (playlist.activeTrack as SourcedTrack).siblings.isEmpty) {
playlistNotifier.populateSibling(); playlistNotifier.populateSibling();
} }
return null; return null;
}, [playlist.activeTrack]); }, [playlist.activeTrack]);
final itemBuilder = useCallback( final itemBuilder = useCallback(
(YoutubeVideoInfo video) { (SourceInfo sourceInfo) {
return ListTile( return ListTile(
title: Text(video.title), title: Text(sourceInfo.title),
leading: Padding( leading: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: UniversalImage( child: UniversalImage(
path: video.thumbnailUrl, path: sourceInfo.thumbnail,
height: 60, height: 60,
width: 60, width: 60,
), ),
@ -104,16 +117,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
trailing: Text(video.duration.toHumanReadableString()), trailing: Text(sourceInfo.duration.toHumanReadableString()),
subtitle: Text(video.channelName), subtitle: Text(sourceInfo.artist),
enabled: playlist.isFetching != true, enabled: playlist.isFetching != true,
selected: playlist.isFetching != true && selected: playlist.isFetching != true &&
video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id, sourceInfo.id ==
(playlist.activeTrack as SourcedTrack).sourceInfo.id,
selectedTileColor: theme.popupMenuTheme.color, selectedTileColor: theme.popupMenuTheme.color,
onTap: () { onTap: () {
if (playlist.isFetching == false && if (playlist.isFetching == false &&
video.id != (playlist.activeTrack as SpotubeTrack).ytTrack.id) { sourceInfo.id !=
playlistNotifier.swapSibling(video); (playlist.activeTrack as SourcedTrack).sourceInfo.id) {
playlistNotifier.swapSibling(sourceInfo);
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
@ -175,7 +190,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
}, },
) )
else ...[ else ...[
if (preferences.youtubeApiType == YoutubeApiType.piped) if (preferences.audioSource == AudioSource.piped)
PopupMenuButton( PopupMenuButton(
icon: const Icon(SpotubeIcons.filter, size: 18), icon: const Icon(SpotubeIcons.filter, size: 18),
onSelected: (SearchMode mode) { onSelected: (SearchMode mode) {

View File

@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -23,7 +24,7 @@ class PlaylistCard extends HookConsumerWidget {
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final queryBowl = QueryClient.of(context); final queryClient = QueryClient.of(context);
final tracks = useState<List<TrackSimple>?>(null); final tracks = useState<List<TrackSimple>?>(null);
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
() => playlistQueue.containsCollection(playlist.id!), () => playlistQueue.containsCollection(playlist.id!),
@ -34,6 +35,31 @@ class PlaylistCard extends HookConsumerWidget {
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
final me = useQueries.user.me(ref); final me = useQueries.user.me(ref);
Future<List<Track>> fetchAllTracks() async {
if (playlist.id == 'user-liked-tracks') {
return await queryClient.fetchQuery(
"user-liked-tracks",
() => useQueries.playlist.likedTracks(spotify),
) ??
[];
}
final query = queryClient.createInfiniteQuery<List<Track>, dynamic, int>(
"playlist-tracks/${playlist.id}",
(page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!),
initialPage: 0,
nextPage: useQueries.playlist.tracksOfQueryNextPage,
);
return await query.fetchAllTracks(
getAllTracks: () async {
final res =
await spotify.playlists.getTracksByPlaylistId(playlist.id!).all();
return res.toList();
},
);
}
return PlaybuttonCard( return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 10), margin: const EdgeInsets.symmetric(horizontal: 10),
title: playlist.name!, title: playlist.name!,
@ -62,18 +88,7 @@ class PlaylistCard extends HookConsumerWidget {
return audioPlayer.resume(); return audioPlayer.resume();
} }
List<Track> fetchedTracks = playlist.id == 'user-liked-tracks' List<Track> fetchedTracks = await fetchAllTracks();
? await queryBowl.fetchQuery(
"user-liked-tracks",
() => useQueries.playlist.likedTracks(spotify, ref),
) ??
[]
: await queryBowl.fetchQuery(
"playlist-tracks/${playlist.id}",
() => useQueries.playlist
.tracksOf(playlist.id!, spotify, ref),
) ??
[];
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
@ -90,11 +105,8 @@ class PlaylistCard extends HookConsumerWidget {
updating.value = true; updating.value = true;
try { try {
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
List<Track> fetchedTracks = await queryBowl.fetchQuery(
"playlist-tracks/${playlist.id}", final fetchedTracks = await fetchAllTracks();
() => useQueries.playlist.tracksOf(playlist.id!, spotify, ref),
) ??
[];
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;

View File

@ -11,9 +11,12 @@ import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistAddTrackDialog extends HookConsumerWidget { class PlaylistAddTrackDialog extends HookConsumerWidget {
/// The id of the playlist this dialog was opened from
final String? openFromPlaylist;
final List<Track> tracks; final List<Track> tracks;
const PlaylistAddTrackDialog({ const PlaylistAddTrackDialog({
required this.tracks, required this.tracks,
required this.openFromPlaylist,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -30,11 +33,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
?.where( ?.where(
(playlist) => (playlist) =>
playlist.owner?.id != null && playlist.owner?.id != null &&
playlist.owner!.id == me.data?.id, playlist.owner!.id == me.data?.id &&
playlist.id != openFromPlaylist,
) )
.toList() ?? .toList() ??
[], [],
[userPlaylists.data, me.data?.id], [userPlaylists.data, me.data?.id, openFromPlaylist],
); );
final playlistsCheck = useState(<String, bool>{}); final playlistsCheck = useState(<String, bool>{});

View File

@ -6,8 +6,7 @@ import 'package:spotube/components/shared/links/hyper_link.dart';
import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
@ -37,8 +36,8 @@ class TrackDetailsDialog extends HookWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.blue), style: const TextStyle(color: Colors.blue),
), ),
context.l10n.duration: (track is SpotubeTrack context.l10n.duration: (track is SourcedTrack
? (track as SpotubeTrack).ytTrack.duration ? (track as SourcedTrack).sourceInfo.duration
: track.duration!) : track.duration!)
.toHumanReadableString(), .toHumanReadableString(),
if (track.album!.releaseDate != null) if (track.album!.releaseDate != null)
@ -46,33 +45,27 @@ class TrackDetailsDialog extends HookWidget {
context.l10n.popularity: track.popularity?.toString() ?? "0", context.l10n.popularity: track.popularity?.toString() ?? "0",
}; };
final ytTrack = final sourceInfo =
track is SpotubeTrack ? (track as SpotubeTrack).ytTrack : null; track is SourcedTrack ? (track as SourcedTrack).sourceInfo : null;
final ytTracksDetailsMap = ytTrack == null final ytTracksDetailsMap = sourceInfo == null
? {} ? {}
: { : {
context.l10n.youtube: Hyperlink( context.l10n.youtube: Hyperlink(
"https://piped.video/watch?v=${ytTrack.id}", "https://piped.video/watch?v=${sourceInfo.id}",
"https://piped.video/watch?v=${ytTrack.id}", "https://piped.video/watch?v=${sourceInfo.id}",
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
context.l10n.channel: Hyperlink( context.l10n.channel: Hyperlink(
ytTrack.channelName, sourceInfo.artist,
"https://youtube.com${ytTrack.channelName}", sourceInfo.artistUrl,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
context.l10n.likes:
PrimitiveUtils.toReadableNumber(ytTrack.likes.toDouble()),
context.l10n.dislikes:
PrimitiveUtils.toReadableNumber(ytTrack.dislikes.toDouble()),
context.l10n.views:
PrimitiveUtils.toReadableNumber(ytTrack.views.toDouble()),
context.l10n.streamUrl: Hyperlink( context.l10n.streamUrl: Hyperlink(
(track as SpotubeTrack).ytUri, (track as SourcedTrack).url,
(track as SpotubeTrack).ytUri, (track as SourcedTrack).url,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),

View File

@ -4,13 +4,15 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
class ExpandableSearchField extends StatelessWidget { class ExpandableSearchField extends StatelessWidget {
final ValueNotifier<bool> isFiltering; final bool isFiltering;
final ValueChanged<bool> onChangeFiltering;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocus; final FocusNode searchFocus;
const ExpandableSearchField({ const ExpandableSearchField({
Key? key, Key? key,
required this.isFiltering, required this.isFiltering,
required this.onChangeFiltering,
required this.searchController, required this.searchController,
required this.searchFocus, required this.searchFocus,
}) : super(key: key); }) : super(key: key);
@ -19,17 +21,17 @@ class ExpandableSearchField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedOpacity( return AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
opacity: isFiltering.value ? 1 : 0, opacity: isFiltering ? 1 : 0,
child: AnimatedSize( child: AnimatedSize(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: SizedBox( child: SizedBox(
height: isFiltering.value ? 50 : 0, height: isFiltering ? 50 : 0,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: CallbackShortcuts( child: CallbackShortcuts(
bindings: { bindings: {
LogicalKeySet(LogicalKeyboardKey.escape): () { LogicalKeySet(LogicalKeyboardKey.escape): () {
isFiltering.value = false; onChangeFiltering(false);
searchController.clear(); searchController.clear();
searchFocus.unfocus(); searchFocus.unfocus();
} }
@ -52,7 +54,7 @@ class ExpandableSearchField extends StatelessWidget {
} }
class ExpandableSearchButton extends StatelessWidget { class ExpandableSearchButton extends StatelessWidget {
final ValueNotifier<bool> isFiltering; final bool isFiltering;
final FocusNode searchFocus; final FocusNode searchFocus;
final Widget icon; final Widget icon;
final ValueChanged<bool>? onPressed; final ValueChanged<bool>? onPressed;
@ -73,18 +75,17 @@ class ExpandableSearchButton extends StatelessWidget {
icon: icon, icon: icon,
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: backgroundColor:
isFiltering.value ? theme.colorScheme.secondaryContainer : null, isFiltering ? theme.colorScheme.secondaryContainer : null,
foregroundColor: isFiltering.value ? theme.colorScheme.secondary : null, foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
minimumSize: const Size(25, 25), minimumSize: const Size(25, 25),
), ),
onPressed: () { onPressed: () {
isFiltering.value = !isFiltering.value; if (isFiltering) {
if (isFiltering.value) {
searchFocus.requestFocus(); searchFocus.requestFocus();
} else { } else {
searchFocus.unfocus(); searchFocus.unfocus();
} }
onPressed?.call(isFiltering.value); onPressed?.call(!isFiltering);
}, },
); );
} }

View File

@ -11,16 +11,6 @@ import 'dart:io' show Platform, exit;
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:local_notifier/local_notifier.dart'; import 'package:local_notifier/local_notifier.dart';
final closeNotification = DesktopTools.createNotification(
title: 'Spotube',
message: 'Running in background. Minimized to System Tray',
actions: [
LocalNotificationAction(text: 'Close The App'),
],
)?..onClickAction = (value) {
exit(0);
};
class PageWindowTitleBar extends StatefulHookConsumerWidget class PageWindowTitleBar extends StatefulHookConsumerWidget
implements PreferredSizeWidget { implements PreferredSizeWidget {
final Widget? leading; final Widget? leading;
@ -113,12 +103,7 @@ class WindowTitleBarButtons extends HookConsumerWidget {
const type = ThemeType.auto; const type = ThemeType.auto;
Future<void> onClose() async { Future<void> onClose() async {
if (preferences.closeBehavior == CloseBehavior.close) { await DesktopTools.window.close();
exit(0);
} else {
await DesktopTools.window.hide();
await closeNotification?.show();
}
} }
useEffect(() { useEffect(() {

View File

@ -63,26 +63,8 @@ class PlaybuttonCard extends HookWidget {
others: 15, others: 15,
); );
final textsHeight = useState(
(textsKey.currentContext?.findRenderObject() as RenderBox?)
?.size
.height ??
110.00,
);
final cleanDescription = useDescription(description); final cleanDescription = useDescription(description);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
textsHeight.value =
(textsKey.currentContext?.findRenderObject() as RenderBox?)
?.size
.height ??
textsHeight.value;
});
return null;
}, [textsKey]);
return Container( return Container(
constraints: BoxConstraints(maxWidth: size), constraints: BoxConstraints(maxWidth: size),
margin: margin, margin: margin,

View File

@ -50,7 +50,7 @@ class ShimmerArtistProfile extends HookWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
const Flexible(child: ShimmerTrackTile(noSliver: true)), const Flexible(child: ShimmerTrackTileGroup(noSliver: true)),
], ],
); );
} }

View File

@ -70,8 +70,7 @@ class ShimmerTrackTilePainter extends CustomPainter {
} }
class ShimmerTrackTile extends StatelessWidget { class ShimmerTrackTile extends StatelessWidget {
final bool noSliver; const ShimmerTrackTile({super.key});
const ShimmerTrackTile({super.key, this.noSliver = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -82,39 +81,42 @@ class ShimmerTrackTile extends StatelessWidget {
shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300], shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300],
); );
return Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
child: CustomPaint(
size: const Size(double.infinity, 60),
painter: ShimmerTrackTilePainter(
background: shimmerTheme.shimmerBackgroundColor ??
theme.scaffoldBackgroundColor,
foreground: shimmerTheme.shimmerColor ?? theme.cardColor,
),
),
);
}
}
class ShimmerTrackTileGroup extends StatelessWidget {
final bool noSliver;
final int count;
const ShimmerTrackTileGroup({
super.key,
this.noSliver = false,
this.count = 5,
});
@override
Widget build(BuildContext context) {
if (noSliver) { if (noSliver) {
return ListView.builder( return ListView.builder(
itemCount: 5, itemCount: 5,
itemBuilder: (context, index) { itemBuilder: (context, index) => const ShimmerTrackTile(),
return Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
child: CustomPaint(
size: const Size(double.infinity, 60),
painter: ShimmerTrackTilePainter(
background: shimmerTheme.shimmerBackgroundColor ??
theme.scaffoldBackgroundColor,
foreground: shimmerTheme.shimmerColor ?? theme.cardColor,
),
),
);
},
); );
} }
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Padding( (BuildContext context, int index) => const ShimmerTrackTile(),
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8), childCount: count,
child: CustomPaint(
size: const Size(double.infinity, 60),
painter: ShimmerTrackTilePainter(
background: shimmerTheme.shimmerBackgroundColor ??
theme.scaffoldBackgroundColor,
foreground: shimmerTheme.shimmerColor ?? theme.cardColor,
),
),
),
childCount: 5,
), ),
); );
} }

View File

@ -1,229 +0,0 @@
import 'dart:ui';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
enum PlayButtonState {
playing,
notPlaying,
loading,
}
class TrackCollectionHeading<T> extends HookConsumerWidget {
final String title;
final String? description;
final String titleImage;
final List<Widget> buttons;
final AlbumSimple? album;
final Query<List<TrackSimple>, T> tracksSnapshot;
final PlayButtonState playingState;
final void Function([Track? currentTrack]) onPlay;
final void Function([Track? currentTrack]) onShuffledPlay;
final PaletteColor? color;
const TrackCollectionHeading({
Key? key,
required this.title,
required this.titleImage,
required this.buttons,
required this.tracksSnapshot,
required this.playingState,
required this.onPlay,
required this.onShuffledPlay,
required this.color,
this.description,
this.album,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final cleanDescription = useDescription(description);
return LayoutBuilder(
builder: (context, constrains) {
return DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(titleImage),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black45,
theme.colorScheme.surface,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: SafeArea(
child: Flex(
direction: constrains.mdAndDown
? Axis.vertical
: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: titleImage,
placeholder: Assets.albumPlaceholder.path,
),
),
),
const SizedBox(width: 10, height: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: AutoSizeText(
title,
style: theme.textTheme.titleLarge!.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
maxLines: 2,
minFontSize: 16,
overflow: TextOverflow.ellipsis,
),
),
if (album != null)
Text(
"${album?.albumType?.formatted}${context.l10n.released}${DateTime.tryParse(
album?.releaseDate ?? "",
)?.year}",
style: theme.textTheme.titleMedium!.copyWith(
color: Colors.white,
fontWeight: FontWeight.normal,
),
),
if (cleanDescription != null)
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: AutoSizeText(
cleanDescription,
style: const TextStyle(color: Colors.white),
maxLines: 2,
overflow: TextOverflow.fade,
minFontSize: 14,
),
),
const SizedBox(height: 10),
IconTheme(
data: theme.iconTheme.copyWith(
color: Colors.white,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: buttons,
),
),
const SizedBox(height: 10),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: Row(
mainAxisSize: constrains.smAndUp
? MainAxisSize.min
: MainAxisSize.min,
children: [
Expanded(
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
label: Text(context.l10n.shuffle),
icon: const Icon(SpotubeIcons.shuffle),
onPressed: tracksSnapshot.data == null ||
playingState ==
PlayButtonState.playing
? null
: onShuffledPlay,
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color?.color,
foregroundColor: color?.bodyTextColor,
),
onPressed: tracksSnapshot.data != null ||
playingState ==
PlayButtonState.loading
? onPlay
: null,
icon: switch (playingState) {
PlayButtonState.playing =>
const Icon(SpotubeIcons.pause),
PlayButtonState.notPlaying =>
const Icon(SpotubeIcons.play),
PlayButtonState.loading =>
const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: .7,
),
),
},
label: Text(
playingState == PlayButtonState.playing
? context.l10n.stop
: context.l10n.play,
),
),
),
],
),
),
],
)
],
),
),
),
),
),
),
);
},
);
}
}

View File

@ -1,274 +0,0 @@
import 'dart:async';
import 'package:fl_query/fl_query.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart';
import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class TrackCollectionView<T> extends HookConsumerWidget {
final logger = getLogger(TrackCollectionView);
final String id;
final String title;
final String? description;
final Query<List<TrackSimple>, T> tracksSnapshot;
final String titleImage;
final PlayButtonState playingState;
final Future<void> Function([Track? currentTrack]) onPlay;
final void Function([Track? currentTrack]) onShuffledPlay;
final void Function() onAddToQueue;
final void Function() onShare;
final Widget? heartBtn;
final AlbumSimple? album;
final bool showShare;
final bool isOwned;
final bool bottomSpace;
final String routePath;
TrackCollectionView({
required this.title,
required this.id,
required this.tracksSnapshot,
required this.titleImage,
required this.playingState,
required this.onPlay,
required this.onShuffledPlay,
required this.onAddToQueue,
required this.onShare,
required this.routePath,
this.heartBtn,
this.album,
this.description,
this.showShare = true,
this.isOwned = false,
this.bottomSpace = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final auth = ref.watch(AuthenticationNotifier.provider);
final color = usePaletteGenerator(titleImage).dominantColor;
final List<Widget> buttons = [
if (showShare)
IconButton(
icon: const Icon(SpotubeIcons.share),
onPressed: onShare,
),
if (isOwned)
IconButton(
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(playlistId: id);
},
);
},
),
if (heartBtn != null && auth != null) heartBtn!,
IconButton(
onPressed: playingState == PlayButtonState.playing
? null
: tracksSnapshot.data != null
? onAddToQueue
: null,
icon: const Icon(
SpotubeIcons.queueAdd,
),
),
];
final controller = useScrollController();
final collapsed = useState(false);
useCustomStatusBarColor(
Colors.transparent,
GoRouterState.of(context).matchedLocation == routePath,
);
useEffect(() {
listener() {
if (controller.position.pixels >= 390 && !collapsed.value) {
collapsed.value = true;
} else if (controller.position.pixels < 390 && collapsed.value) {
collapsed.value = false;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [collapsed.value]);
return Scaffold(
appBar: kIsDesktop
? const PageWindowTitleBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
leadingWidth: 400,
leading: Align(
alignment: Alignment.centerLeft,
child: BackButton(color: Colors.white),
),
)
: null,
extendBodyBehindAppBar: kIsDesktop,
body: RefreshIndicator(
onRefresh: () async {
await tracksSnapshot.refresh();
},
child: InterScrollbar(
controller: controller,
child: CustomScrollView(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverAppBar(
actions: [
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: buttons,
),
),
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: IconButton(
tooltip: context.l10n.shuffle,
icon: const Icon(SpotubeIcons.shuffle),
onPressed: playingState == PlayButtonState.playing
? null
: onShuffledPlay,
),
),
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
backgroundColor: theme.colorScheme.inversePrimary,
),
onPressed: tracksSnapshot.data != null ? onPlay : null,
child: switch (playingState) {
PlayButtonState.playing =>
const Icon(SpotubeIcons.pause),
PlayButtonState.notPlaying =>
const Icon(SpotubeIcons.play),
PlayButtonState.loading => const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: .7,
),
),
},
),
),
],
floating: false,
pinned: true,
expandedHeight: 400,
automaticallyImplyLeading: kIsMobile,
leading:
kIsMobile ? const BackButton(color: Colors.white) : null,
iconTheme: IconThemeData(color: color?.titleTextColor),
primary: true,
backgroundColor: color?.color.withOpacity(.8),
title: collapsed.value
? Text(
title,
style: theme.textTheme.titleMedium!.copyWith(
color: color?.titleTextColor,
fontWeight: FontWeight.w600,
),
)
: null,
centerTitle: true,
flexibleSpace: FlexibleSpaceBar(
background: TrackCollectionHeading<T>(
color: color,
title: title,
description: description,
titleImage: titleImage,
playingState: playingState,
onPlay: onPlay,
onShuffledPlay: onShuffledPlay,
tracksSnapshot: tracksSnapshot,
buttons: buttons,
album: album,
),
),
),
HookBuilder(
builder: (context) {
if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) {
return const ShimmerTrackTile();
} else if (tracksSnapshot.hasError) {
return SliverToBoxAdapter(
child: Text(
context.l10n.error(tracksSnapshot.error ?? ""),
),
);
}
return TracksTableView(
(tracksSnapshot.data ?? []).map(
(track) {
if (track is Track) {
return track;
} else {
return TypeConversionUtils.simpleTrack_X_Track(
track,
album!,
);
}
},
).toList(),
onTrackPlayButtonPressed: onPlay,
playlistId: id,
userPlaylist: isOwned,
onFiltering: () {
// scroll the flexible space
// to allow more space for search results
controller.animateTo(
330,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
},
);
},
)
],
),
),
));
}
}

View File

@ -1,368 +0,0 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/utils/service_utils.dart';
final trackCollectionSortState =
StateProvider.family<SortBy, String>((ref, _) => SortBy.none);
class TracksTableView extends HookConsumerWidget {
final Future<void> Function(Track currentTrack)? onTrackPlayButtonPressed;
final List<Track> tracks;
final bool userPlaylist;
final String? playlistId;
final bool isSliver;
final Widget? heading;
final VoidCallback? onFiltering;
const TracksTableView(
this.tracks, {
Key? key,
this.onTrackPlayButtonPressed,
this.onFiltering,
this.userPlaylist = false,
this.playlistId,
this.heading,
this.isSliver = true,
}) : super(key: key);
@override
Widget build(context, ref) {
final mediaQuery = MediaQuery.of(context);
ref.watch(ProxyPlaylistNotifier.provider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final apiType =
ref.watch(userPreferencesProvider.select((s) => s.youtubeApiType));
const tableHeadStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
final selected = useState<List<String>>([]);
final showCheck = useState<bool>(false);
final sortBy = ref.watch(trackCollectionSortState(playlistId ?? ''));
final isFiltering = useState<bool>(false);
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
final controller = useScrollController();
// this will trigger update on each change in searchController
useValueListenable(searchController);
final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) {
return tracks;
}
return tracks
.map((e) => (weightedRatio(e.name!, searchController.text), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}, [tracks, searchController.text]);
final sortedTracks = useMemoized(
() {
return ServiceUtils.sortTracks(filteredTracks, sortBy);
},
[filteredTracks, sortBy],
);
final selectedTracks = useMemoized(
() => sortedTracks.where(
(track) => selected.value.contains(track.id),
),
[sortedTracks],
);
final children = tracks.isEmpty
? [const NotFound(vertical: true)]
: [
if (heading != null) heading!,
LayoutBuilder(builder: (context, constrains) {
return Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: child,
),
);
},
child: showCheck.value
? Checkbox(
value: selected.value.length == sortedTracks.length,
onChanged: (checked) {
if (!showCheck.value) showCheck.value = true;
if (checked == true) {
selected.value =
sortedTracks.map((s) => s.id!).toList();
} else {
selected.value = [];
showCheck.value = false;
}
},
)
: constrains.mdAndUp
? const SizedBox(width: 32)
: const SizedBox(width: 16),
),
Expanded(
flex: 7,
child: Row(
children: [
Text(
context.l10n.title,
style: tableHeadStyle,
overflow: TextOverflow.ellipsis,
),
],
),
),
// used alignment of this table-head
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Row(
children: [
Text(
context.l10n.album,
overflow: TextOverflow.ellipsis,
style: tableHeadStyle,
),
],
),
),
SortTracksDropdown(
value: sortBy,
onChanged: (value) {
ref
.read(trackCollectionSortState(playlistId ?? '')
.notifier)
.state = value;
},
),
ExpandableSearchButton(
isFiltering: isFiltering,
searchFocus: searchFocus,
onPressed: (value) {
if (isFiltering.value) {
onFiltering?.call();
}
},
),
AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: tableHeadStyle,
),
],
onSelected: (action) async {
switch (action) {
case "download":
{
final confirmed = apiType == YoutubeApiType.piped ||
await showDialog(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
);
if (confirmed != true) return;
await downloader
.batchAddToQueue(selectedTracks.toList());
if (context.mounted) {
selected.value = [];
showCheck.value = false;
}
break;
}
case "add-to-playlist":
{
if (context.mounted) {
await showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
tracks: selectedTracks.toList(),
);
},
);
}
break;
}
case "play-next":
{
playback.addTracksAtFirst(selectedTracks);
if (playlistId != null) {
playback.addCollection(playlistId!);
}
selected.value = [];
showCheck.value = false;
break;
}
case "add-to-queue":
{
playback.addTracks(selectedTracks);
if (playlistId != null) {
playback.addCollection(playlistId!);
}
selected.value = [];
showCheck.value = false;
break;
}
default:
}
},
icon: const Icon(SpotubeIcons.moreVertical),
children: [
PopSheetEntry(
value: "download",
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.download_count(selectedTracks.length),
),
),
if (!userPlaylist)
PopSheetEntry(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n
.add_count_to_playlist(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(
context.l10n
.add_count_to_queue(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
title: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
),
const SizedBox(width: 10),
],
);
}),
ExpandableSearchField(
isFiltering: isFiltering,
searchController: searchController,
searchFocus: searchFocus,
),
...sortedTracks.mapIndexed((i, track) {
return TrackTile(
index: i,
track: track,
selected: selected.value.contains(track.id),
userPlaylist: userPlaylist,
playlistId: playlistId,
onTap: () async {
if (showCheck.value) {
final alreadyChecked = selected.value.contains(track.id);
if (alreadyChecked) {
selected.value =
selected.value.where((id) => id != track.id).toList();
} else {
selected.value = [...selected.value, track.id!];
}
} else {
final isBlackListed = ref.read(
BlackListNotifier.provider.select(
(blacklist) => blacklist.contains(
BlacklistedElement.track(track.id!, track.name!),
),
),
);
if (isBlackListed) return;
await onTrackPlayButtonPressed?.call(track);
}
},
onLongPress: () {
if (showCheck.value) return;
showCheck.value = true;
selected.value = [...selected.value, track.id!];
},
onChanged: !showCheck.value
? null
: (value) {
if (value == null) return;
if (value) {
selected.value = [...selected.value, track.id!];
} else {
selected.value = selected.value
.where((id) => id != track.id)
.toList();
}
},
);
}),
// extra space for mobile devices where keyboard takes half of the screen
if (isFiltering.value)
SizedBox(
height: mediaQuery.size.height * .75, //75% of the screen
),
];
if (isSliver) {
return SliverSafeArea(
top: false,
sliver: SliverList(
delegate: SliverChildListDelegate(children),
),
);
}
return SafeArea(
child: ListView(
controller: controller,
children: children,
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
@ -12,6 +13,7 @@ import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
@ -22,6 +24,7 @@ import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
enum TrackOptionValue { enum TrackOptionValue {
album,
share, share,
addToPlaylist, addToPlaylist,
addToQueue, addToQueue,
@ -64,20 +67,27 @@ class TrackOptions extends HookConsumerWidget {
}); });
} }
void actionAddToPlaylist(BuildContext context, Track track) { void actionAddToPlaylist(
BuildContext context,
Track track,
) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => PlaylistAddTrackDialog( builder: (context) => PlaylistAddTrackDialog(
tracks: [track], tracks: [track],
openFromPlaylist: playlistId,
), ),
); );
} }
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context);
final router = GoRouter.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier); final playback = ref.watch(ProxyPlaylistNotifier.notifier);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(AuthenticationNotifier.provider);
ref.watch(downloadManagerProvider); ref.watch(downloadManagerProvider);
final downloadManager = ref.watch(downloadManagerProvider.notifier); final downloadManager = ref.watch(downloadManagerProvider.notifier);
@ -110,7 +120,7 @@ class TrackOptions extends HookConsumerWidget {
]); ]);
final progressNotifier = useMemoized(() { final progressNotifier = useMemoized(() {
final spotubeTrack = downloadManager.mapToSpotubeTrack(track); final spotubeTrack = downloadManager.mapToSourcedTrack(track);
if (spotubeTrack == null) return null; if (spotubeTrack == null) return null;
return downloadManager.getProgressNotifier(spotubeTrack); return downloadManager.getProgressNotifier(spotubeTrack);
}); });
@ -118,6 +128,12 @@ class TrackOptions extends HookConsumerWidget {
final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>( final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>(
onSelected: (value) async { onSelected: (value) async {
switch (value) { switch (value) {
case TrackOptionValue.album:
await router.push(
'/album/${track.album!.id}',
extra: track.album!,
);
break;
case TrackOptionValue.delete: case TrackOptionValue.delete:
await File((track as LocalTrack).path).delete(); await File((track as LocalTrack).path).delete();
ref.refresh(localTracksProvider); ref.refresh(localTracksProvider);
@ -229,6 +245,13 @@ class TrackOptions extends HookConsumerWidget {
) )
], ],
_ => [ _ => [
if (mediaQuery.smAndDown)
PopSheetEntry(
value: TrackOptionValue.album,
leading: const Icon(SpotubeIcons.album),
title: Text(context.l10n.go_to_album),
subtitle: Text(track.album!.name!),
),
if (!playlist.containsTrack(track)) ...[ if (!playlist.containsTrack(track)) ...[
PopSheetEntry( PopSheetEntry(
value: TrackOptionValue.addToQueue, value: TrackOptionValue.addToQueue,

View File

@ -9,7 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/hover_builder.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/components/shared/track_table/track_options.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';

View File

@ -0,0 +1,129 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TrackViewBodySection extends HookConsumerWidget {
const TrackViewBodySection({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
useValueListenable(searchController);
final searchQuery = searchController.text;
final isFiltering = useState(false);
final uniqTracks = useMemoized(() {
final trackIds = props.tracks.map((e) => e.id).toSet();
return props.tracks.where((e) => trackIds.remove(e.id)).toList();
}, [props.tracks]);
final tracks = useMemoized(() {
List<Track> filteredTracks;
if (searchQuery.isEmpty) {
filteredTracks = uniqTracks;
} else {
filteredTracks = uniqTracks
.map((e) => (weightedRatio(e.name!, searchQuery), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}
return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy);
}, [trackViewState.sortBy, searchQuery, uniqTracks]);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final isActive = playlist.collections.contains(props.collectionId);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: TrackViewBodyHeaders(
isFiltering: isFiltering,
searchFocus: searchFocus,
),
),
const SliverGap(8),
SliverToBoxAdapter(
child: ExpandableSearchField(
isFiltering: isFiltering.value,
onChangeFiltering: (value) {
isFiltering.value = value;
},
searchController: searchController,
searchFocus: searchFocus,
),
),
SliverSafeArea(
top: false,
sliver: SliverInfiniteList(
itemCount: tracks.length,
onFetchData: props.pagination.onFetchMore,
isLoading: props.pagination.isLoading,
hasReachedMax: !props.pagination.hasNextPage,
loadingBuilder: (context) => const ShimmerTrackTile(),
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(
track: track,
index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!),
playlistId: props.collectionId,
userPlaylist: isUserPlaylist,
onChanged: !trackViewState.isSelecting
? null
: (value) {
trackViewState.toggleTrackSelection(track.id!);
},
onLongPress: () {
trackViewState.selectTrack(track.id!);
},
onTap: () async {
if (trackViewState.isSelecting) {
trackViewState.toggleTrackSelection(track.id!);
return;
}
if (isActive || playlist.tracks.contains(track)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.load(
tracks,
initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(props.collectionId);
}
},
);
},
),
),
],
);
}
}

View File

@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_options.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
class TrackViewBodyHeaders extends HookConsumerWidget {
final ValueNotifier<bool> isFiltering;
final FocusNode searchFocus;
const TrackViewBodyHeaders({
Key? key,
required this.isFiltering,
required this.searchFocus,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
return LayoutBuilder(
builder: (context, constrains) {
return Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: child,
),
);
},
child: Checkbox(
value: trackViewState.hasSelectedAll,
onChanged: (checked) {
if (checked == true) {
trackViewState.selectAll();
} else {
trackViewState.deselectAll();
}
},
),
),
Expanded(
flex: 7,
child: Row(
children: [
Text(
context.l10n.title,
style: textTheme.bodyLarge,
overflow: TextOverflow.ellipsis,
),
],
),
),
// used alignment of this table-head
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Row(
children: [
Text(
context.l10n.album,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge,
),
],
),
),
SortTracksDropdown(
value: trackViewState.sortBy,
onChanged: (value) {
trackViewState.sort(value);
},
),
ExpandableSearchButton(
isFiltering: isFiltering.value,
searchFocus: searchFocus,
onPressed: (value) {
isFiltering.value = value;
if (value) {
searchFocus.requestFocus();
} else {
searchFocus.unfocus();
}
},
),
const TrackViewBodyOptions(),
],
);
},
);
}
}

View File

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
class TrackViewBodyOptions extends HookConsumerWidget {
const TrackViewBodyOptions({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:textTheme) = Theme.of(context);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final selectedTracks = trackViewState.selectedTracks;
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: textTheme.bodyLarge,
),
],
onSelected: (action) async {
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
await showDialog(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
);
if (confirmed != true) return;
await downloader.batchAddToQueue(selectedTracks);
trackViewState.deselectAll();
break;
}
case "add-to-playlist":
{
if (context.mounted) {
await showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
openFromPlaylist: props.collectionId,
tracks: selectedTracks.toList(),
);
},
);
}
break;
}
case "play-next":
{
playlistNotifier.addTracksAtFirst(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
trackViewState.deselectAll();
break;
}
case "add-to-queue":
{
playlistNotifier.addTracks(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
trackViewState.deselectAll();
break;
}
default:
}
},
icon: const Icon(SpotubeIcons.moreVertical),
children: [
PopSheetEntry(
value: "download",
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.download_count(selectedTracks.length),
),
),
PopSheetEntry(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.add_count_to_playlist(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(
context.l10n.add_count_to_queue(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
title: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
);
}
}

View File

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

View File

@ -0,0 +1,149 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/header_actions.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/header_buttons.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:gap/gap.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart';
class TrackViewFlexHeader extends HookConsumerWidget {
const TrackViewFlexHeader({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context);
final defaultTextStyle = DefaultTextStyle.of(context);
final mediaQuery = MediaQuery.of(context);
final description = useDescription(props.description);
final palette = usePaletteColor(props.image, ref);
return IconTheme(
data: iconTheme.copyWith(color: palette.bodyTextColor),
child: SliverLayoutBuilder(
builder: (context, constrains) {
final isExpanded = constrains.scrollOffset < 350;
final headingStyle = (mediaQuery.mdAndDown
? textTheme.headlineSmall
: textTheme.headlineMedium)
?.copyWith(
color: palette.bodyTextColor,
);
return SliverAppBar(
iconTheme: iconTheme.copyWith(
color: palette.bodyTextColor,
size: 16,
),
actions: isExpanded
? []
: [
const TrackViewHeaderActions(),
TrackViewHeaderButtons(compact: true, color: palette),
],
floating: false,
pinned: true,
expandedHeight: 450,
automaticallyImplyLeading: DesktopTools.platform.isMobile,
backgroundColor: palette.color,
title: isExpanded ? null : Text(props.title, style: headingStyle),
flexibleSpace: FlexibleSpaceBar(
background: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(props.image),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black45,
colorScheme.surface,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flex(
direction: mediaQuery.mdAndDown
? Axis.vertical
: Axis.horizontal,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: props.image,
width: 200,
height: 200,
placeholder: Assets.albumPlaceholder.path,
),
),
const Gap(20),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: mediaQuery.mdAndDown
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
Text(props.title, style: headingStyle),
const SizedBox(height: 10),
if (description != null &&
description.isNotEmpty)
Text(
description,
style: defaultTextStyle.style.copyWith(
color: palette.bodyTextColor,
),
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Gap(10),
const TrackViewHeaderActions(),
const Gap(10),
TrackViewHeaderButtons(color: palette),
],
),
],
),
],
),
),
),
),
),
),
),
);
},
),
);
}
}

View File

@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
class TrackViewHeaderActions extends HookConsumerWidget {
const TrackViewHeaderActions({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final isActive = playlist.collections.contains(props.collectionId);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final auth = ref.watch(AuthenticationNotifier.provider);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: context.l10n.share,
icon: const Icon(SpotubeIcons.share),
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: props.shareUrl),
);
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
"Copied ${props.shareUrl} to clipboard",
textAlign: TextAlign.center,
),
),
);
},
),
IconButton(
icon: const Icon(SpotubeIcons.queueAdd),
tooltip: context.l10n.add_to_queue,
onPressed: isActive || props.tracks.isEmpty
? null
: () async {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(props.collectionId);
},
),
if (props.onHeart != null && auth != null)
HeartButton(
isLiked: props.isLiked,
icon: isUserPlaylist ? SpotubeIcons.trash : null,
tooltip: props.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
onPressed: () {
props.onHeart?.call();
if (isUserPlaylist) {
context.pop();
}
},
),
if (isUserPlaylist)
IconButton(
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(
playlistId: props.collectionId,
trackIds: props.tracks.map((e) => e.id!).toList(),
);
},
);
},
),
],
);
}
}

View File

@ -0,0 +1,137 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
class TrackViewHeaderButtons extends HookConsumerWidget {
final PaletteColor color;
final bool compact;
const TrackViewHeaderButtons({
Key? key,
required this.color,
this.compact = false,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final isActive = playlist.collections.contains(props.collectionId);
final isLoading = useState(false);
const progressIndicator = Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: .8),
),
);
void onShuffle() async {
try {
isLoading.value = true;
final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.load(
allTracks,
autoPlay: true,
initialIndex: Random().nextInt(allTracks.length),
);
await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(props.collectionId);
} finally {
isLoading.value = false;
}
}
void onPlay() async {
try {
isLoading.value = true;
final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.load(allTracks, autoPlay: true);
playlistNotifier.addCollection(props.collectionId);
} finally {
isLoading.value = false;
}
}
if (compact) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isActive && !isLoading.value)
IconButton(
icon: const Icon(SpotubeIcons.shuffle),
onPressed: props.tracks.isEmpty ? null : onShuffle,
),
const Gap(10),
IconButton.filledTonal(
icon: isActive
? const Icon(SpotubeIcons.pause)
: isLoading.value
? progressIndicator
: const Icon(SpotubeIcons.play),
onPressed: isActive || props.tracks.isEmpty || isLoading.value
? null
: onPlay,
),
const Gap(10),
],
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: isActive || isLoading.value ? 0 : 1,
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox.square(
dimension: isActive || isLoading.value ? 0 : null,
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
minimumSize: const Size(150, 40)),
label: Text(context.l10n.shuffle),
icon: const Icon(SpotubeIcons.shuffle),
onPressed: props.tracks.isEmpty ? null : onShuffle,
),
),
),
),
const Gap(10),
FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color.color,
foregroundColor: color.bodyTextColor,
minimumSize: const Size(150, 40)),
onPressed: isActive || props.tracks.isEmpty || isLoading.value
? null
: onPlay,
icon: isActive
? const Icon(SpotubeIcons.pause)
: isLoading.value
? progressIndicator
: const Icon(SpotubeIcons.play),
label: Text(context.l10n.play),
),
],
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
class TrackView extends HookConsumerWidget {
const TrackView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
return Scaffold(
appBar: DesktopTools.platform.isDesktop
? const PageWindowTitleBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
leadingWidth: 400,
leading: Align(
alignment: Alignment.centerLeft,
child: BackButton(color: Colors.white),
),
)
: null,
extendBodyBehindAppBar: true,
body: RefreshIndicator(
onRefresh: props.pagination.onRefresh,
child: CustomScrollView(
slivers: [
const TrackViewFlexHeader(),
SliverAnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: props.tracks.isEmpty
? const ShimmerTrackTileGroup()
: const TrackViewBodySection(),
),
],
),
),
);
}
}

View File

@ -0,0 +1,107 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart' hide Page;
import 'package:spotify/spotify.dart';
class PaginationProps {
final bool hasNextPage;
final bool isLoading;
final VoidCallback onFetchMore;
final Future<void> Function() onRefresh;
final Future<List<Track>> Function() onFetchAll;
const PaginationProps({
required this.hasNextPage,
required this.isLoading,
required this.onFetchMore,
required this.onFetchAll,
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 &&
other.hasNextPage == hasNextPage &&
other.isLoading == isLoading &&
other.onFetchMore == onFetchMore &&
other.onFetchAll == onFetchAll &&
other.onRefresh == onRefresh;
}
@override
int get hashCode =>
super.hashCode ^
hasNextPage.hashCode ^
isLoading.hashCode ^
onFetchMore.hashCode ^
onFetchAll.hashCode ^
onRefresh.hashCode;
}
class InheritedTrackView extends InheritedWidget {
final String collectionId;
final String title;
final String? description;
final String image;
final String routePath;
final List<Track> tracks;
final PaginationProps pagination;
final bool isLiked;
final String shareUrl;
// events
final VoidCallback? onHeart; // if null heart button will hidden
const InheritedTrackView({
super.key,
required super.child,
required this.collectionId,
required this.title,
this.description,
required this.image,
required this.tracks,
required this.pagination,
required this.routePath,
required this.shareUrl,
this.isLiked = false,
this.onHeart,
});
@override
bool updateShouldNotify(InheritedTrackView oldWidget) {
return oldWidget.title != title ||
oldWidget.description != description ||
oldWidget.image != image ||
oldWidget.tracks != tracks ||
oldWidget.pagination != pagination ||
oldWidget.isLiked != isLiked ||
oldWidget.onHeart != onHeart ||
oldWidget.shareUrl != shareUrl ||
oldWidget.routePath != routePath ||
oldWidget.collectionId != collectionId ||
oldWidget.child != child;
}
static InheritedTrackView of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<InheritedTrackView>();
if (widget == null) {
throw Exception(
'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]',
);
}
return widget;
}
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
class TrackViewNotifier extends ChangeNotifier {
List<Track> tracks;
List<String> selectedTrackIds;
SortBy sortBy;
String? searchQuery;
TrackViewNotifier(
this.tracks, {
this.selectedTrackIds = const [],
this.sortBy = SortBy.none,
this.searchQuery,
});
bool get isSelecting => selectedTrackIds.isNotEmpty;
bool get hasSelectedAll =>
selectedTrackIds.length == tracks.length && tracks.isNotEmpty;
List<Track> get selectedTracks =>
tracks.where((e) => selectedTrackIds.contains(e.id)).toList();
void selectTrack(String trackId) {
selectedTrackIds = [...selectedTrackIds, trackId];
notifyListeners();
}
void unselectTrack(String trackId) {
selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList();
notifyListeners();
}
void toggleTrackSelection(String trackId) {
if (selectedTrackIds.contains(trackId)) {
unselectTrack(trackId);
} else {
selectTrack(trackId);
}
}
void selectAll() {
selectedTrackIds = tracks.map((e) => e.id!).toList();
notifyListeners();
}
void deselectAll() {
selectedTrackIds = [];
notifyListeners();
}
void sort(SortBy sortBy) {
this.sortBy = sortBy;
notifyListeners();
}
}
final trackViewProvider = ChangeNotifierProvider.autoDispose
.family<TrackViewNotifier, List<Track>>((ref, tracks) {
return TrackViewNotifier(tracks);
});

28
lib/extensions/color.dart Normal file
View File

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
extension ColorAlterer on Color {
Color darken(double amount) {
assert(amount >= 0 && amount <= 1);
final hsl = HSLColor.fromColor(this);
final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
return hslDark.toColor();
}
Color lighten(double amount) {
assert(amount >= 0 && amount <= 1);
final hsl = HSLColor.fromColor(this);
final hslLight =
hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0));
return hslLight.toColor();
}
bool isLight() {
final luminance = computeLuminance();
return luminance > 0.5;
}
bool isDark() {
final luminance = computeLuminance();
return luminance <= 0.5;
}
}

View File

@ -1,3 +1,4 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
// ignore: constant_identifier_names // ignore: constant_identifier_names
@ -9,6 +10,29 @@ const Breakpoints = (
xl: 1280.0, xl: 1280.0,
); );
extension SliverBreakpoints on SliverConstraints {
bool get isXs => crossAxisExtent <= Breakpoints.xs;
bool get isSm =>
crossAxisExtent > Breakpoints.xs && crossAxisExtent <= Breakpoints.sm;
bool get isMd =>
crossAxisExtent > Breakpoints.sm && crossAxisExtent <= Breakpoints.md;
bool get isLg =>
crossAxisExtent > Breakpoints.md && crossAxisExtent <= Breakpoints.lg;
bool get isXl =>
crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl;
bool get is2Xl => crossAxisExtent > Breakpoints.xl;
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
bool get lgAndUp => isLg || isXl || is2Xl;
bool get xlAndUp => isXl || is2Xl;
bool get smAndDown => isXs || isSm;
bool get mdAndDown => isXs || isSm || isMd;
bool get lgAndDown => isXs || isSm || isMd || isLg;
bool get xlAndDown => isXs || isSm || isMd || isLg || isXl;
}
extension ContainerBreakpoints on BoxConstraints { extension ContainerBreakpoints on BoxConstraints {
bool get isXs => biggest.width <= Breakpoints.xs; bool get isXs => biggest.width <= Breakpoints.xs;
bool get isSm => bool get isSm =>

View File

@ -0,0 +1,34 @@
import 'package:fl_query/fl_query.dart';
import 'package:spotify/spotify.dart';
extension FetchAllTracks on InfiniteQuery<List<Track>, dynamic, int> {
Future<List<Track>> fetchAllTracks({
required Future<List<Track>> Function() getAllTracks,
}) async {
if (pages.isNotEmpty && !hasNextPage) {
return pages.expand((page) => page).toList();
}
final tracks = await getAllTracks();
final numOfPages = (tracks.length / 20).round();
final Map<int, List<Track>> pagedTracks = {};
for (var i = 0; i < numOfPages; i++) {
if (i == numOfPages - 1) {
final pageTracks = tracks.sublist(i * 20);
pagedTracks[i] = pageTracks;
break;
}
final pageTracks = tracks.sublist(i * 20, (i + 1) * 20);
pagedTracks[i] = pageTracks;
}
for (final group in pagedTracks.entries) {
setPageData(group.key, group.value);
}
return tracks.toList();
}
}

View File

@ -0,0 +1,11 @@
import 'package:html_unescape/html_unescape.dart';
final htmlEscape = HtmlUnescape();
extension UnescapeHtml on String {
String unescapeHtml() => htmlEscape.convert(this);
}
extension NullableUnescapeHtml on String? {
String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!);
}

View File

@ -0,0 +1,32 @@
import 'dart:io';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/hooks/configurators/use_window_listener.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:local_notifier/local_notifier.dart';
final closeNotification = DesktopTools.createNotification(
title: 'Spotube',
message: 'Running in background. Minimized to System Tray',
actions: [
LocalNotificationAction(text: 'Close The App'),
],
)?..onClickAction = (value) {
exit(0);
};
void useCloseBehavior(WidgetRef ref) {
useWindowListener(
onWindowClose: () async {
final preferences = ref.read(userPreferencesProvider);
if (preferences.closeBehavior == CloseBehavior.minimizeToTray) {
await DesktopTools.window.hide();
closeNotification?.show();
} else {
exit(0);
}
},
);
}

View File

@ -0,0 +1,197 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class CallbackWindowListener implements WindowListener {
final VoidCallback? _onWindowClose;
final VoidCallback? _onWindowFocus;
final VoidCallback? _onWindowBlur;
final VoidCallback? _onWindowMaximize;
final VoidCallback? _onWindowUnmaximize;
final VoidCallback? _onWindowMinimize;
final VoidCallback? _onWindowRestore;
final VoidCallback? _onWindowResize;
final VoidCallback? _onWindowResized;
final VoidCallback? _onWindowMove;
final VoidCallback? _onWindowMoved;
final VoidCallback? _onWindowEnterFullScreen;
final VoidCallback? _onWindowLeaveFullScreen;
final VoidCallback? _onWindowDocked;
final VoidCallback? _onWindowUndocked;
final VoidCallback? _onWindowEvent;
const CallbackWindowListener({
VoidCallback? onWindowClose,
VoidCallback? onWindowFocus,
VoidCallback? onWindowBlur,
VoidCallback? onWindowMaximize,
VoidCallback? onWindowUnmaximize,
VoidCallback? onWindowMinimize,
VoidCallback? onWindowRestore,
VoidCallback? onWindowResize,
VoidCallback? onWindowResized,
VoidCallback? onWindowMove,
VoidCallback? onWindowMoved,
VoidCallback? onWindowEnterFullScreen,
VoidCallback? onWindowLeaveFullScreen,
VoidCallback? onWindowDocked,
VoidCallback? onWindowUndocked,
VoidCallback? onWindowEvent,
}) : _onWindowClose = onWindowClose,
_onWindowFocus = onWindowFocus,
_onWindowBlur = onWindowBlur,
_onWindowMaximize = onWindowMaximize,
_onWindowUnmaximize = onWindowUnmaximize,
_onWindowMinimize = onWindowMinimize,
_onWindowRestore = onWindowRestore,
_onWindowResize = onWindowResize,
_onWindowResized = onWindowResized,
_onWindowMove = onWindowMove,
_onWindowMoved = onWindowMoved,
_onWindowEnterFullScreen = onWindowEnterFullScreen,
_onWindowLeaveFullScreen = onWindowLeaveFullScreen,
_onWindowDocked = onWindowDocked,
_onWindowUndocked = onWindowUndocked,
_onWindowEvent = onWindowEvent;
@override
void onWindowBlur() {
return _onWindowBlur?.call();
}
@override
void onWindowClose() {
return _onWindowClose?.call();
}
@override
void onWindowDocked() {
return _onWindowDocked?.call();
}
@override
void onWindowEnterFullScreen() {
return _onWindowEnterFullScreen?.call();
}
@override
void onWindowEvent(String eventName) {
return _onWindowEvent?.call();
}
@override
void onWindowFocus() {
return _onWindowFocus?.call();
}
@override
void onWindowLeaveFullScreen() {
return _onWindowLeaveFullScreen?.call();
}
@override
void onWindowMaximize() {
return _onWindowMaximize?.call();
}
@override
void onWindowMinimize() {
return _onWindowMinimize?.call();
}
@override
void onWindowMove() {
return _onWindowMove?.call();
}
@override
void onWindowMoved() {
return _onWindowMoved?.call();
}
@override
void onWindowResize() {
return _onWindowResize?.call();
}
@override
void onWindowResized() {
return _onWindowResized?.call();
}
@override
void onWindowRestore() {
return _onWindowRestore?.call();
}
@override
void onWindowUndocked() {
return _onWindowUndocked?.call();
}
@override
void onWindowUnmaximize() {
return _onWindowUnmaximize?.call();
}
}
void useWindowListener({
VoidCallback? onWindowClose,
VoidCallback? onWindowFocus,
VoidCallback? onWindowBlur,
VoidCallback? onWindowMaximize,
VoidCallback? onWindowUnmaximize,
VoidCallback? onWindowMinimize,
VoidCallback? onWindowRestore,
VoidCallback? onWindowResize,
VoidCallback? onWindowResized,
VoidCallback? onWindowMove,
VoidCallback? onWindowMoved,
VoidCallback? onWindowEnterFullScreen,
VoidCallback? onWindowLeaveFullScreen,
VoidCallback? onWindowDocked,
VoidCallback? onWindowUndocked,
VoidCallback? onWindowEvent,
}) {
useEffect(() {
final listener = CallbackWindowListener(
onWindowClose: onWindowClose,
onWindowFocus: onWindowFocus,
onWindowBlur: onWindowBlur,
onWindowMaximize: onWindowMaximize,
onWindowUnmaximize: onWindowUnmaximize,
onWindowMinimize: onWindowMinimize,
onWindowRestore: onWindowRestore,
onWindowResize: onWindowResize,
onWindowResized: onWindowResized,
onWindowMove: onWindowMove,
onWindowMoved: onWindowMoved,
onWindowEnterFullScreen: onWindowEnterFullScreen,
onWindowLeaveFullScreen: onWindowLeaveFullScreen,
onWindowDocked: onWindowDocked,
onWindowUndocked: onWindowUndocked,
onWindowEvent: onWindowEvent,
);
DesktopTools.window.addListener(listener);
return () {
DesktopTools.window.removeListener(listener);
};
}, [
onWindowClose,
onWindowFocus,
onWindowBlur,
onWindowMaximize,
onWindowUnmaximize,
onWindowMinimize,
onWindowRestore,
onWindowResize,
onWindowResized,
onWindowMove,
onWindowMoved,
onWindowEnterFullScreen,
onWindowLeaveFullScreen,
onWindowDocked,
onWindowUndocked,
onWindowEvent,
]);
}

View File

@ -251,7 +251,7 @@
"developers": "المطورون", "developers": "المطورون",
"not_logged_in": "لم تقم بتسجيل الدخول", "not_logged_in": "لم تقم بتسجيل الدخول",
"search_mode": "وضع البحث", "search_mode": "وضع البحث",
"youtube_api_type": "نوع الـAPI", "audio_source": "مصدر الصوت",
"ok": "حسسناً", "ok": "حسسناً",
"failed_to_encrypt": "فشل في التشفير", "failed_to_encrypt": "فشل في التشفير",
"encryption_failed_warning": "يستخدم Spotube التشفير لتخزين بياناتك بشكل آمن. لكنها فشلت في القيام بذلك. لذلك سيعود الأمر إلى التخزين غير الآمن\nإذا كنت تستخدم Linux، فيرجى التأكد من تثبيت أي خدمة سرية (gnome-keyring، kde-wallet، keepassxc، إلخ)", "encryption_failed_warning": "يستخدم Spotube التشفير لتخزين بياناتك بشكل آمن. لكنها فشلت في القيام بذلك. لذلك سيعود الأمر إلى التخزين غير الآمن\nإذا كنت تستخدم Linux، فيرجى التأكد من تثبيت أي خدمة سرية (gnome-keyring، kde-wallet، keepassxc، إلخ)",

View File

@ -249,7 +249,7 @@
"developers": "ডেভেলপার", "developers": "ডেভেলপার",
"not_logged_in": "আপনি লগইন করা নেই", "not_logged_in": "আপনি লগইন করা নেই",
"search_mode": "অনুসন্ধান মোড", "search_mode": "অনুসন্ধান মোড",
"youtube_api_type": "API প্রকার", "audio_source": "অডিও উৎস",
"ok": "ঠিক আছে", "ok": "ঠিক আছে",
"failed_to_encrypt": "এনক্রিপ্ট করা ব্যর্থ হয়েছে", "failed_to_encrypt": "এনক্রিপ্ট করা ব্যর্থ হয়েছে",
"encryption_failed_warning": "Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে", "encryption_failed_warning": "Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে",

View File

@ -249,7 +249,7 @@
"developers": "Desenvolupadors", "developers": "Desenvolupadors",
"not_logged_in": "No ha iniciat sesió", "not_logged_in": "No ha iniciat sesió",
"search_mode": "Mode de cerca", "search_mode": "Mode de cerca",
"youtube_api_type": "Tipus d'API de YouTube", "audio_source": "Font d'àudio",
"ok": "OK", "ok": "OK",
"failed_to_encrypt": "Error al xifrar", "failed_to_encrypt": "Error al xifrar",
"encryption_failed_warning": "Spotube utilitza el xifrado per emmagatzemar les seves dades de forma segura. Però ha fallat. Per tant, tornarà a un emmagatzament no segur\nSi estè utilizant Linux, asseguri's de tenir instal·lats els serveis secrets com gnome-keyring, kde-wallet i keepassxc", "encryption_failed_warning": "Spotube utilitza el xifrado per emmagatzemar les seves dades de forma segura. Però ha fallat. Per tant, tornarà a un emmagatzament no segur\nSi estè utilizant Linux, asseguri's de tenir instal·lats els serveis secrets com gnome-keyring, kde-wallet i keepassxc",

View File

@ -249,7 +249,7 @@
"developers": "Entwickler", "developers": "Entwickler",
"not_logged_in": "Sie sind nicht angemeldet", "not_logged_in": "Sie sind nicht angemeldet",
"search_mode": "Suchmodus", "search_mode": "Suchmodus",
"youtube_api_type": "API-Typ", "audio_source": "Audioquelle",
"ok": "OK", "ok": "OK",
"failed_to_encrypt": "Verschlüsselung fehlgeschlagen", "failed_to_encrypt": "Verschlüsselung fehlgeschlagen",
"encryption_failed_warning": "Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben", "encryption_failed_warning": "Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben",

View File

@ -251,7 +251,7 @@
"developers": "Developers", "developers": "Developers",
"not_logged_in": "You're not logged in", "not_logged_in": "You're not logged in",
"search_mode": "Search Mode", "search_mode": "Search Mode",
"youtube_api_type": "API Type", "audio_source": "Audio Source",
"ok": "Ok", "ok": "Ok",
"failed_to_encrypt": "Failed to encrypt", "failed_to_encrypt": "Failed to encrypt",
"encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed", "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed",
@ -279,5 +279,6 @@
"password": "Password", "password": "Password",
"login": "Login", "login": "Login",
"login_with_your_lastfm": "Login with your Last.fm account", "login_with_your_lastfm": "Login with your Last.fm account",
"scrobble_to_lastfm": "Scrobble to Last.fm" "scrobble_to_lastfm": "Scrobble to Last.fm",
"go_to_album": "Go to Album"
} }

View File

@ -249,7 +249,7 @@
"developers": "Desarrolladores", "developers": "Desarrolladores",
"not_logged_in": "No has iniciado sesión", "not_logged_in": "No has iniciado sesión",
"search_mode": "Modo de búsqueda", "search_mode": "Modo de búsqueda",
"youtube_api_type": "Tipo de API de YouTube", "audio_source": "Fuente de audio",
"ok": "OK", "ok": "OK",
"failed_to_encrypt": "Error al cifrar", "failed_to_encrypt": "Error al cifrar",
"encryption_failed_warning": "Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc", "encryption_failed_warning": "Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc",

View File

@ -251,7 +251,7 @@
"developers": "توسعه دهنده ها", "developers": "توسعه دهنده ها",
"not_logged_in": "شما وارد نشده اید ", "not_logged_in": "شما وارد نشده اید ",
"search_mode": "حالت جستجو", "search_mode": "حالت جستجو",
"youtube_api_type": "API نوع", "audio_source": "منبع صدا",
"ok": "باشد", "ok": "باشد",
"failed_to_encrypt": "رمز گذاری نشده", "failed_to_encrypt": "رمز گذاری نشده",
"encryption_failed_warning": "Spotube از رمزگذاری برای ذخیره ایمن داده های شما استفاده می کند. اما موفق به انجام این کار نشد. بنابراین به فضای ذخیره‌سازی ناامن تبدیل می‌شود\nاگر از لینوکس استفاده می‌کنید، لطفاً مطمئن شوید که سرویس مخفی (gnome-keyring، kde-wallet، keepassxc و غیره) را نصب کرده‌اید.", "encryption_failed_warning": "Spotube از رمزگذاری برای ذخیره ایمن داده های شما استفاده می کند. اما موفق به انجام این کار نشد. بنابراین به فضای ذخیره‌سازی ناامن تبدیل می‌شود\nاگر از لینوکس استفاده می‌کنید، لطفاً مطمئن شوید که سرویس مخفی (gnome-keyring، kde-wallet، keepassxc و غیره) را نصب کرده‌اید.",

View File

@ -249,7 +249,7 @@
"developers": "Développeurs", "developers": "Développeurs",
"not_logged_in": "Vous n'êtes pas connecté(e)", "not_logged_in": "Vous n'êtes pas connecté(e)",
"search_mode": "Mode de recherche", "search_mode": "Mode de recherche",
"youtube_api_type": "Type d'API", "audio_source": "Source audio",
"ok": "OK", "ok": "OK",
"failed_to_encrypt": "Échec de la cryptage", "failed_to_encrypt": "Échec de la cryptage",
"encryption_failed_warning": "Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc", "encryption_failed_warning": "Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc",

View File

@ -249,7 +249,7 @@
"developers": "डेवलपर्स", "developers": "डेवलपर्स",
"not_logged_in": "आप लॉग इन नहीं हैं", "not_logged_in": "आप लॉग इन नहीं हैं",
"search_mode": "खोज मोड", "search_mode": "खोज मोड",
"youtube_api_type": "API प्रकार", "audio_source": "ऑडियो स्रोत",
"ok": "ठीक है", "ok": "ठीक है",
"failed_to_encrypt": "एन्क्रिप्ट करने में विफल रहा", "failed_to_encrypt": "एन्क्रिप्ट करने में विफल रहा",
"encryption_failed_warning": "Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है", "encryption_failed_warning": "Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है",

View File

@ -249,7 +249,7 @@
"developers": "開発", "developers": "開発",
"not_logged_in": "ログインしていません", "not_logged_in": "ログインしていません",
"search_mode": "検索モード", "search_mode": "検索モード",
"youtube_api_type": "APIの種類", "audio_source": "音声ソース",
"ok": "分かりました", "ok": "分かりました",
"failed_to_encrypt": "暗号化に失敗しました", "failed_to_encrypt": "暗号化に失敗しました",
"encryption_failed_warning": "Spotubeはデータを安全に保存するために暗号化を使用しています。しかし、失敗しました。したがって、安全でないストレージにフォールバックします\nLinuxを使用している場合は、gnome-keyring、kde-wallet、keepassxcなどのシークレットサービスがインストールされていることを確認してください", "encryption_failed_warning": "Spotubeはデータを安全に保存するために暗号化を使用しています。しかし、失敗しました。したがって、安全でないストレージにフォールバックします\nLinuxを使用している場合は、gnome-keyring、kde-wallet、keepassxcなどのシークレットサービスがインストールされていることを確認してください",

View File

@ -249,7 +249,7 @@
"developers": "Developerzy", "developers": "Developerzy",
"not_logged_in": "Nie jesteś zalogowany", "not_logged_in": "Nie jesteś zalogowany",
"search_mode": "Tryb szukania", "search_mode": "Tryb szukania",
"youtube_api_type": "Typ API", "audio_source": "Źródło dźwięku",
"ok": "Ok", "ok": "Ok",
"failed_to_encrypt": "Nie można zaszyfrować :(", "failed_to_encrypt": "Nie można zaszyfrować :(",
"encryption_failed_warning": "Spotube używa szyfrowania do bezpiecznego przechowywania danych. Ale nie udało się tego zrobić. Więc powróci do niezabezpieczonego przechowywania\nJeśli używasz Linuksa, upewnij się, że masz zainstalowane jakieś usługi do szyfrowania (gnome-keyring, kde-wallet, keepassxc itp.)", "encryption_failed_warning": "Spotube używa szyfrowania do bezpiecznego przechowywania danych. Ale nie udało się tego zrobić. Więc powróci do niezabezpieczonego przechowywania\nJeśli używasz Linuksa, upewnij się, że masz zainstalowane jakieś usługi do szyfrowania (gnome-keyring, kde-wallet, keepassxc itp.)",

View File

@ -249,7 +249,7 @@
"developers": "Desenvolvedores", "developers": "Desenvolvedores",
"not_logged_in": "Você não está logado", "not_logged_in": "Você não está logado",
"search_mode": "Modo de Busca", "search_mode": "Modo de Busca",
"youtube_api_type": "Tipo de API", "audio_source": "Fonte de Áudio",
"ok": "Ok", "ok": "Ok",
"failed_to_encrypt": "Falha ao criptografar", "failed_to_encrypt": "Falha ao criptografar",
"encryption_failed_warning": "O Spotube usa criptografia para armazenar seus dados com segurança, mas falhou em fazê-lo. Portanto, ele voltará para o armazenamento não seguro.\nSe você estiver usando o Linux, certifique-se de ter algum serviço secreto (gnome-keyring, kde-wallet, keepassxc, etc.) instalado", "encryption_failed_warning": "O Spotube usa criptografia para armazenar seus dados com segurança, mas falhou em fazê-lo. Portanto, ele voltará para o armazenamento não seguro.\nSe você estiver usando o Linux, certifique-se de ter algum serviço secreto (gnome-keyring, kde-wallet, keepassxc, etc.) instalado",

View File

@ -249,7 +249,7 @@
"developers": "Разработчики", "developers": "Разработчики",
"not_logged_in": "Вы не выполнили вход", "not_logged_in": "Вы не выполнили вход",
"search_mode": "Режим поиска", "search_mode": "Режим поиска",
"youtube_api_type": "Тип API", "audio_source": "Источник аудио",
"ok": "Ок", "ok": "Ок",
"failed_to_encrypt": "Не удалось зашифровать", "failed_to_encrypt": "Не удалось зашифровать",
"encryption_failed_warning": "Spotube использует шифрование для безопасного хранения ваших данных. Однако в этом случае произошла ошибка. Поэтому будет использовано небезопасное хранилище.\nЕсли вы используете Linux, убедитесь, что у вас установлен какой-либо инструмент для работы с секретами (gnome-keyring, kde-wallet, keepassxc и т.д.)", "encryption_failed_warning": "Spotube использует шифрование для безопасного хранения ваших данных. Однако в этом случае произошла ошибка. Поэтому будет использовано небезопасное хранилище.\nЕсли вы используете Linux, убедитесь, что у вас установлен какой-либо инструмент для работы с секретами (gnome-keyring, kde-wallet, keepassxc и т.д.)",

View File

@ -251,7 +251,7 @@
"developers": "Geliştiriciler", "developers": "Geliştiriciler",
"not_logged_in": "Giriş yapmadınız", "not_logged_in": "Giriş yapmadınız",
"search_mode": "Arama Modu", "search_mode": "Arama Modu",
"youtube_api_type": "API Türü", "audio_source": "Ses Kaynağı",
"ok": "Tamam", "ok": "Tamam",
"failed_to_encrypt": "Şifreleme başarısız oldu", "failed_to_encrypt": "Şifreleme başarısız oldu",
"encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.", "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.",

View File

@ -251,7 +251,7 @@
"developers": "Розробники", "developers": "Розробники",
"not_logged_in": "Ви не ввійшли в обліковий запис", "not_logged_in": "Ви не ввійшли в обліковий запис",
"search_mode": "Режим пошуку", "search_mode": "Режим пошуку",
"youtube_api_type": "Тип API", "audio_source": "Джерело аудіо",
"ok": "Гаразд", "ok": "Гаразд",
"failed_to_encrypt": "Не вдалося зашифрувати", "failed_to_encrypt": "Не вдалося зашифрувати",
"encryption_failed_warning": "Spotube використовує шифрування для безпечного зберігання ваших даних. Але не вдалося цього зробити. Тому він перейде до небезпечного зберігання\nЯкщо ви використовуєте Linux, переконайтеся, що у вас встановлено будь-який секретний сервіс (gnome-keyring, kde-wallet, keepassxc тощо)", "encryption_failed_warning": "Spotube використовує шифрування для безпечного зберігання ваших даних. Але не вдалося цього зробити. Тому він перейде до небезпечного зберігання\nЯкщо ви використовуєте Linux, переконайтеся, що у вас встановлено будь-який секретний сервіс (gnome-keyring, kde-wallet, keepassxc тощо)",

View File

@ -249,7 +249,7 @@
"developers": "开发者", "developers": "开发者",
"not_logged_in": "你尚未登录", "not_logged_in": "你尚未登录",
"search_mode": "搜索模式", "search_mode": "搜索模式",
"youtube_api_type": "API 类型", "audio_source": "音频源",
"ok": "确定", "ok": "确定",
"failed_to_encrypt": "加密失败", "failed_to_encrypt": "加密失败",
"encryption_failed_warning": "Spotube使用加密来安全地存储您的数据。但是失败了。因此它将回退到不安全的存储\n如果您使用Linux请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务", "encryption_failed_warning": "Spotube使用加密来安全地存储您的数据。但是失败了。因此它将回退到不安全的存储\n如果您使用Linux请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务",

View File

@ -1,5 +1,6 @@
import 'package:catcher_2/catcher_2.dart'; import 'package:catcher_2/catcher_2.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:dart_discord_rpc/dart_discord_rpc.dart';
import 'package:device_preview/device_preview.dart'; import 'package:device_preview/device_preview.dart';
import 'package:fl_query/fl_query.dart'; import 'package:fl_query/fl_query.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -15,12 +16,13 @@ import 'package:metadata_god/metadata_god.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/intents.dart'; import 'package:spotube/collections/intents.dart';
import 'package:spotube/hooks/configurators/use_close_behavior.dart';
import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart'; import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart';
import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; import 'package:spotube/hooks/configurators/use_get_storage_perms.dart';
import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/source_match.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -49,6 +51,10 @@ Future<void> main(List<String> rawArgs) async {
await AndroidUtils.setHighRefreshRate(); await AndroidUtils.setHighRefreshRate();
} }
if (DesktopTools.platform.isDesktop) {
await DesktopTools.window.setPreventClose(true);
}
await DesktopTools.ensureInitialized( await DesktopTools.ensureInitialized(
DesktopWindowOptions( DesktopWindowOptions(
hideTitleBar: true, hideTitleBar: true,
@ -64,6 +70,10 @@ Future<void> main(List<String> rawArgs) async {
MetadataGod.initialize(); MetadataGod.initialize();
} }
if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) {
DiscordRPC.initialize();
}
final hiveCacheDir = final hiveCacheDir =
kIsWeb ? null : (await getApplicationSupportDirectory()).path; kIsWeb ? null : (await getApplicationSupportDirectory()).path;
@ -72,16 +82,18 @@ Future<void> main(List<String> rawArgs) async {
cacheDir: hiveCacheDir, cacheDir: hiveCacheDir,
connectivity: FlQueryInternetConnectionCheckerAdapter(), connectivity: FlQueryInternetConnectionCheckerAdapter(),
); );
Hive.registerAdapter(MatchedTrackAdapter());
Hive.registerAdapter(SkipSegmentAdapter()); Hive.registerAdapter(SkipSegmentAdapter());
Hive.registerAdapter(SearchModeAdapter());
Hive.registerAdapter(SourceMatchAdapter());
Hive.registerAdapter(SourceTypeAdapter());
// Cache versioning entities with Adapter // Cache versioning entities with Adapter
MatchedTrack.version = 'v1'; SourceMatch.version = 'v1';
SkipSegment.version = 'v1'; SkipSegment.version = 'v1';
await Hive.openLazyBox<MatchedTrack>( await Hive.openLazyBox<SourceMatch>(
MatchedTrack.boxName, SourceMatch.boxName,
path: hiveCacheDir, path: hiveCacheDir,
); );
await Hive.openLazyBox( await Hive.openLazyBox(
@ -171,6 +183,7 @@ class SpotubeState extends ConsumerState<Spotube> {
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
useInitSysTray(ref); useInitSysTray(ref);
useCloseBehavior(ref);
useEffect(() { useEffect(() {
FlutterNativeSplash.remove(); FlutterNativeSplash.remove();
@ -178,7 +191,6 @@ class SpotubeState extends ConsumerState<Spotube> {
/// For enabling hot reload for audio player /// For enabling hot reload for audio player
if (!kDebugMode) return; if (!kDebugMode) return;
audioPlayer.dispose(); audioPlayer.dispose();
// youtube.close();
}; };
}, []); }, []);

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class CurrentPlaylist { class CurrentPlaylist {
List<Track>? _tempTrack; List<Track>? _tempTrack;
@ -18,13 +19,13 @@ class CurrentPlaylist {
this.isLocal = false, this.isLocal = false,
}); });
static CurrentPlaylist fromJson(Map<String, dynamic> map) { static CurrentPlaylist fromJson(Map<String, dynamic> map, Ref ref) {
return CurrentPlaylist( return CurrentPlaylist(
id: map["id"], id: map["id"],
tracks: List.castFrom<dynamic, Track>(map["tracks"] tracks: List.castFrom<dynamic, Track>(map["tracks"]
.map( .map(
(track) => map["isLocal"] == true (track) => map["isLocal"] == true
? SpotubeTrack.fromJson(track) ? SourcedTrack.fromJson(track, ref: ref)
: Track.fromJson(track), : Track.fromJson(track),
) )
.toList()), .toList()),
@ -66,7 +67,7 @@ class CurrentPlaylist {
"name": name, "name": name,
"tracks": tracks "tracks": tracks
.map((track) => .map((track) =>
track is SpotubeTrack ? track.toJson() : track.toJson()) track is SourcedTrack ? track.toJson() : track.toJson())
.toList(), .toList(),
"thumbnail": thumbnail, "thumbnail": thumbnail,
"isLocal": isLocal, "isLocal": isLocal,

View File

@ -1,69 +0,0 @@
import "package:hive/hive.dart";
part "matched_track.g.dart";
@HiveType(typeId: 1)
class MatchedTrack {
@HiveField(0)
String youtubeId;
@HiveField(1)
String spotifyId;
@HiveField(2)
SearchMode searchMode;
String? id;
DateTime? createdAt;
bool get isSynced => id != null;
static String version = 'v1';
static final boxName = "oss.krtirtho.spotube.matched_tracks.$version";
static LazyBox<MatchedTrack> get box => Hive.lazyBox<MatchedTrack>(boxName);
MatchedTrack({
required this.youtubeId,
required this.spotifyId,
required this.searchMode,
this.id,
this.createdAt,
});
factory MatchedTrack.fromJson(Map<String, dynamic> json) {
return MatchedTrack(
searchMode: SearchMode.fromString(json["searchMode"]),
youtubeId: json["youtube_id"],
spotifyId: json["spotify_id"],
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
);
}
Map<String, dynamic> toJson() {
return {
"youtube_id": youtubeId,
"spotify_id": spotifyId,
"id": id,
"searchMode": searchMode.name,
"created_at": createdAt?.toString()
}..removeWhere((key, value) => value == null);
}
}
@HiveType(typeId: 4)
enum SearchMode {
@HiveField(0)
youtube._internal('YouTube'),
@HiveField(1)
youtubeMusic._internal('YouTube Music');
final String label;
const SearchMode._internal(this.label);
factory SearchMode.fromString(String value) {
return SearchMode.values.firstWhere(
(element) => element.name == value,
orElse: () => SearchMode.youtube,
);
}
}

View File

@ -1,86 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'matched_track.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MatchedTrackAdapter extends TypeAdapter<MatchedTrack> {
@override
final int typeId = 1;
@override
MatchedTrack read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return MatchedTrack(
youtubeId: fields[0] as String,
spotifyId: fields[1] as String,
searchMode: fields[2] as SearchMode,
);
}
@override
void write(BinaryWriter writer, MatchedTrack obj) {
writer
..writeByte(3)
..writeByte(0)
..write(obj.youtubeId)
..writeByte(1)
..write(obj.spotifyId)
..writeByte(2)
..write(obj.searchMode);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MatchedTrackAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SearchModeAdapter extends TypeAdapter<SearchMode> {
@override
final int typeId = 4;
@override
SearchMode read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return SearchMode.youtube;
case 1:
return SearchMode.youtubeMusic;
default:
return SearchMode.youtube;
}
}
@override
void write(BinaryWriter writer, SearchMode obj) {
switch (obj) {
case SearchMode.youtube:
writer.writeByte(0);
break;
case SearchMode.youtubeMusic:
writer.writeByte(1);
break;
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SearchModeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -0,0 +1,54 @@
import 'package:hive/hive.dart';
import 'package:json_annotation/json_annotation.dart';
part 'source_match.g.dart';
@JsonEnum()
@HiveType(typeId: 5)
enum SourceType {
@HiveField(0)
youtube._("YouTube"),
@HiveField(1)
youtubeMusic._("YouTube Music"),
@HiveField(2)
jiosaavn._("JioSaavn");
final String label;
const SourceType._(this.label);
}
@JsonSerializable()
@HiveType(typeId: 6)
class SourceMatch {
@HiveField(0)
String id;
@HiveField(1)
String sourceId;
@HiveField(2)
SourceType sourceType;
@HiveField(3)
DateTime createdAt;
SourceMatch({
required this.id,
required this.sourceId,
required this.sourceType,
required this.createdAt,
});
factory SourceMatch.fromJson(Map<String, dynamic> json) =>
_$SourceMatchFromJson(json);
Map<String, dynamic> toJson() => _$SourceMatchToJson(this);
static String version = 'v1';
static final boxName = "oss.krtirtho.spotube.source_matches.$version";
static LazyBox<SourceMatch> get box => Hive.lazyBox<SourceMatch>(boxName);
}

View File

@ -0,0 +1,119 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'source_match.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class SourceMatchAdapter extends TypeAdapter<SourceMatch> {
@override
final int typeId = 6;
@override
SourceMatch read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return SourceMatch(
id: fields[0] as String,
sourceId: fields[1] as String,
sourceType: fields[2] as SourceType,
createdAt: fields[3] as DateTime,
);
}
@override
void write(BinaryWriter writer, SourceMatch obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.sourceId)
..writeByte(2)
..write(obj.sourceType)
..writeByte(3)
..write(obj.createdAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SourceMatchAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SourceTypeAdapter extends TypeAdapter<SourceType> {
@override
final int typeId = 5;
@override
SourceType read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return SourceType.youtube;
case 1:
return SourceType.youtubeMusic;
case 2:
return SourceType.jiosaavn;
default:
return SourceType.youtube;
}
}
@override
void write(BinaryWriter writer, SourceType obj) {
switch (obj) {
case SourceType.youtube:
writer.writeByte(0);
break;
case SourceType.youtubeMusic:
writer.writeByte(1);
break;
case SourceType.jiosaavn:
writer.writeByte(2);
break;
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SourceTypeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SourceMatch _$SourceMatchFromJson(Map<String, dynamic> json) => SourceMatch(
id: json['id'] as String,
sourceId: json['sourceId'] as String,
sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']),
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$SourceMatchToJson(SourceMatch instance) =>
<String, dynamic>{
'id': instance.id,
'sourceId': instance.sourceId,
'sourceType': _$SourceTypeEnumMap[instance.sourceType]!,
'createdAt': instance.createdAt.toIso8601String(),
};
const _$SourceTypeEnumMap = {
SourceType.youtube: 'youtube',
SourceType.youtubeMusic: 'youtubeMusic',
SourceType.jiosaavn: 'jiosaavn',
};

View File

@ -1,274 +0,0 @@
import 'dart:async';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:collection/collection.dart';
final officialMusicRegex = RegExp(
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
caseSensitive: false,
);
class TrackNotFoundException implements Exception {
factory TrackNotFoundException(Track track) {
throw Exception("Failed to find any results for ${track.name}");
}
}
class SpotubeTrack extends Track {
final YoutubeVideoInfo ytTrack;
final String ytUri;
final MusicCodec codec;
final List<YoutubeVideoInfo> siblings;
SpotubeTrack(
this.ytTrack,
this.ytUri,
this.siblings,
this.codec,
) : super();
SpotubeTrack.fromTrack({
required Track track,
required this.ytTrack,
required this.ytUri,
required this.siblings,
required this.codec,
}) : super() {
album = track.album;
artists = track.artists;
availableMarkets = track.availableMarkets;
discNumber = track.discNumber;
durationMs = track.durationMs;
explicit = track.explicit;
externalIds = track.externalIds;
externalUrls = track.externalUrls;
href = track.href;
id = track.id;
isPlayable = track.isPlayable;
linkedFrom = track.linkedFrom;
name = track.name;
popularity = track.popularity;
previewUrl = track.previewUrl;
trackNumber = track.trackNumber;
type = track.type;
uri = track.uri;
}
static Future<List<YoutubeVideoInfo>> fetchSiblings(
Track track,
YoutubeEndpoints client,
) async {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
final title = ServiceUtils.getTitle(
track.name!,
artists: artists,
onlyCleanArtist: true,
).trim();
final query = "$title - ${artists.join(", ")}";
final List<YoutubeVideoInfo> siblings = await client.search(query).then(
(res) {
final isYoutubeApi =
client.preferences.youtubeApiType == YoutubeApiType.youtube;
final siblings = isYoutubeApi ||
client.preferences.searchMode == SearchMode.youtube
? ServiceUtils.onlyContainsEnglish(query)
? res
: res
.sorted((a, b) => b.views.compareTo(a.views))
.map((sibling) {
int score = 0;
for (final artist in artists) {
final isSameChannelArtist =
sibling.channelName.toLowerCase() ==
artist.toLowerCase();
final channelContainsArtist = sibling.channelName
.toLowerCase()
.contains(artist.toLowerCase());
if (isSameChannelArtist || channelContainsArtist) {
score += 1;
}
final titleContainsArtist = sibling.title
.toLowerCase()
.contains(artist.toLowerCase());
if (titleContainsArtist) {
score += 1;
}
}
final titleContainsTrackName = sibling.title
.toLowerCase()
.contains(track.name!.toLowerCase());
final hasOfficialFlag = officialMusicRegex
.hasMatch(sibling.title.toLowerCase());
if (titleContainsTrackName) {
score += 3;
}
if (hasOfficialFlag) {
score += 1;
}
if (hasOfficialFlag && titleContainsTrackName) {
score += 2;
}
return (sibling: sibling, score: score);
})
.sorted((a, b) => b.score.compareTo(a.score))
.map((e) => e.sibling)
: res.sorted((a, b) => b.views.compareTo(a.views)).where((item) {
return artists.any(
(artist) =>
artist.toLowerCase() == item.channelName.toLowerCase(),
);
});
return siblings.take(10).toList();
},
);
return siblings;
}
static Future<SpotubeTrack> fetchFromTrack(
Track track,
YoutubeEndpoints client,
MusicCodec codec,
) async {
final matchedCachedTrack = await MatchedTrack.box.get(track.id!);
var siblings = <YoutubeVideoInfo>[];
YoutubeVideoInfo ytVideo;
String ytStreamUrl;
if (matchedCachedTrack != null &&
matchedCachedTrack.searchMode == client.preferences.searchMode) {
(ytVideo, ytStreamUrl) = await client.video(
matchedCachedTrack.youtubeId, matchedCachedTrack.searchMode, codec);
} else {
siblings = await fetchSiblings(track, client);
if (siblings.isEmpty) {
throw TrackNotFoundException(track);
}
(ytVideo, ytStreamUrl) = await client.video(
siblings.first.id,
siblings.first.searchMode,
codec,
);
await MatchedTrack.box.put(
track.id!,
MatchedTrack(
youtubeId: ytVideo.id,
spotifyId: track.id!,
searchMode: siblings.first.searchMode,
),
);
}
return SpotubeTrack.fromTrack(
track: track,
ytTrack: ytVideo,
ytUri: ytStreamUrl,
siblings: siblings,
codec: codec,
);
}
Future<SpotubeTrack?> swappedCopy(
YoutubeVideoInfo video,
YoutubeEndpoints client,
) async {
// sibling tracks that were manually searched and swapped
final isStepSibling = siblings.none((element) => element.id == video.id);
final (ytVideo, ytStreamUrl) = await client.video(
video.id,
siblings.first.searchMode,
// siblings are always swapped when streaming
client.preferences.streamMusicCodec,
);
if (!isStepSibling) {
await MatchedTrack.box.put(
id!,
MatchedTrack(
youtubeId: video.id,
spotifyId: id!,
searchMode: siblings.first.searchMode,
),
);
}
return SpotubeTrack.fromTrack(
track: this,
ytTrack: ytVideo,
ytUri: ytStreamUrl,
siblings: [
video,
...siblings.where((element) => element.id != video.id),
],
codec: client.preferences.streamMusicCodec,
);
}
static SpotubeTrack fromJson(Map<String, dynamic> map) {
return SpotubeTrack.fromTrack(
track: Track.fromJson(map),
ytTrack: YoutubeVideoInfo.fromJson(map["ytTrack"]),
ytUri: map["ytUri"],
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => YoutubeVideoInfo.fromJson(sibling))
.toList(),
codec: MusicCodec.values.firstWhere(
(element) => element.name == map["codec"],
orElse: () => MusicCodec.m4a,
),
);
}
Future<SpotubeTrack> populatedCopy(YoutubeEndpoints client) async {
if (this.siblings.isNotEmpty) return this;
final siblings = await fetchSiblings(
this,
client,
);
return SpotubeTrack.fromTrack(
track: this,
ytTrack: ytTrack,
ytUri: ytUri,
siblings: siblings,
codec: codec,
);
}
Map<String, dynamic> toJson() {
return {
// super values
...TrackJson.trackToJson(this),
// this values
"ytTrack": ytTrack.toJson(),
"ytUri": ytUri,
"siblings": siblings.map((sibling) => sibling.toJson()).toList(),
"codec": codec.name,
};
}
}

View File

@ -1,157 +1,79 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class AlbumPage extends HookConsumerWidget { class AlbumPage extends HookConsumerWidget {
final AlbumSimple album; final AlbumSimple album;
const AlbumPage(this.album, {Key? key}) : super(key: key); const AlbumPage({
Key? key,
Future<void> playPlaylist( required this.album,
List<Track> tracks, }) : super(key: key);
WidgetRef ref, {
Track? currentTrack,
}) async {
final playlist = ref.read(ProxyPlaylistNotifier.provider);
final playback = ref.read(ProxyPlaylistNotifier.notifier);
final sortBy = ref.read(trackCollectionSortState(album.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isAlbumPlaying = playlist.containsTracks(tracks);
if (!isAlbumPlaying) {
playback.addCollection(album.id!); // for enabling loading indicator
await playback.load(
sortedTracks,
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
);
playback.addCollection(album.id!);
} else if (isAlbumPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final spotify = ref.watch(spotifyProvider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier); final tracksQuery = useQueries.album.tracksOf(ref, album);
final tracksSnapshot = useQueries.album.tracksOf(ref, album.id!); final tracks = useMemoized(() {
return tracksQuery.pages.expand((element) => element).toList();
}, [tracksQuery.pages]);
final albumArt = useMemoized( final client = useQueryClient();
() => TypeConversionUtils.image_X_UrlString(
album.images,
placeholder: ImagePlaceholder.albumArt,
),
[album.images]);
final mediaQuery = MediaQuery.of(context); final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!);
final isLiked = albumIsSaved.data ?? false;
final isAlbumPlaying = useMemoized( final toggleAlbumLike = useMutations.album.toggleFavorite(
() => playlist.collections.contains(album.id!), ref,
[playlist, album], album.id!,
); refreshQueries: [albumIsSaved.key],
onData: (_, __) async {
final albumTrackPlaying = useMemoized( await client.refreshInfiniteQueryAllPages("current-user-albums");
() =>
tracksSnapshot.data?.any((s) => s.id! == playlist.activeTrack?.id!) ==
true &&
playlist.activeTrack is SpotubeTrack,
[playlist.activeTrack, tracksSnapshot.data],
);
return TrackCollectionView(
id: album.id!,
playingState: isAlbumPlaying && albumTrackPlaying
? PlayButtonState.playing
: isAlbumPlaying && !albumTrackPlaying
? PlayButtonState.loading
: PlayButtonState.notPlaying,
title: album.name!,
titleImage: albumArt,
tracksSnapshot: tracksSnapshot,
album: album,
routePath: "/album/${album.id}",
bottomSpace: mediaQuery.mdAndDown,
onPlay: ([track]) async {
if (tracksSnapshot.hasData) {
if (!isAlbumPlaying) {
await playPlaylist(
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(),
ref,
);
} else if (isAlbumPlaying && track != null) {
await playPlaylist(
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(),
currentTrack: track,
ref,
);
} else {
await playback
.removeTracks(tracksSnapshot.data!.map((track) => track.id!));
}
}
}, },
onAddToQueue: () { );
if (tracksSnapshot.hasData && !isAlbumPlaying) {
playback.addTracks( return InheritedTrackView(
tracksSnapshot.data! collectionId: album.id!,
image: TypeConversionUtils.image_X_UrlString(
album.images,
placeholder: ImagePlaceholder.albumArt,
),
title: album.name!,
description:
"${context.l10n.released}${album.releaseDate}${album.artists!.first.name}",
tracks: tracks,
pagination: PaginationProps.fromQuery(
tracksQuery,
onFetchAll: () {
return tracksQuery.fetchAllTracks(getAllTracks: () async {
final res = await spotify.albums.tracks(album.id!).all();
return res
.map((track) => .map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album)) TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(), .toList();
); });
playback.addCollection(album.id!); },
} ),
}, routePath: "/album/${album.id}",
onShare: () { shareUrl: album.externalUrls!.spotify!,
Clipboard.setData( isLiked: isLiked,
ClipboardData(text: "https://open.spotify.com/album/${album.id}"), onHeart: albumIsSaved.hasData
); ? () {
}, toggleAlbumLike.mutate(isLiked);
heartBtn: AlbumHeartButton(album: album), }
onShuffledPlay: ([track]) { : null,
// Shuffle the tracks (create a copy of playlist) child: const TrackView(),
if (tracksSnapshot.hasData) {
final tracks = tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList()
..shuffle();
if (!isAlbumPlaying) {
playPlaylist(
tracks,
ref,
);
} else if (isAlbumPlaying && track != null) {
playPlaylist(
tracks,
ref,
currentTrack: track,
);
} else {
// TODO: Disable ability to stop playback from playlist/album
// playback.stop();
}
}
},
); );
} }
} }

View File

@ -1,32 +1,19 @@
import 'package:collection/collection.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/artist/artist_album_list.dart'; import 'package:spotube/components/artist/artist_album_list.dart';
import 'package:spotube/components/artist/artist_card.dart'; import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/pages/artist/section/footer.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/pages/artist/section/header.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/pages/artist/section/related_artists.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/pages/artist/section/top_tracks.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class ArtistPage extends HookConsumerWidget { class ArtistPage extends HookConsumerWidget {
final String artistId; final String artistId;
final logger = getLogger(ArtistPage); final logger = getLogger(ArtistPage);
@ -34,427 +21,61 @@ class ArtistPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
SpotifyApi spotify = ref.watch(spotifyProvider); final scrollController = useScrollController();
final parentScrollController = useScrollController();
final theme = Theme.of(context); final theme = Theme.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final textTheme = theme.textTheme;
final chipTextVariant = useBreakpointValue(
xs: textTheme.bodySmall,
sm: textTheme.bodySmall,
md: textTheme.bodyMedium,
lg: textTheme.bodyLarge,
xl: textTheme.titleSmall,
xxl: textTheme.titleMedium,
);
final mediaQuery = MediaQuery.of(context); final artistQuery = useQueries.artist.get(ref, artistId);
final avatarWidth = useBreakpointValue(
xs: mediaQuery.size.width * 0.50,
sm: mediaQuery.size.width * 0.50,
md: mediaQuery.size.width * 0.40,
lg: mediaQuery.size.width * 0.18,
xl: mediaQuery.size.width * 0.18,
xxl: mediaQuery.size.width * 0.18,
);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final auth = ref.watch(AuthenticationNotifier.provider);
final queryClient = useQueryClient();
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
leading: BackButton(), leading: BackButton(),
backgroundColor: Colors.transparent,
), ),
body: HookBuilder( extendBodyBehindAppBar: true,
builder: (context) { body: Builder(builder: (context) {
final artistsQuery = useQueries.artist.get(ref, artistId); if (artistQuery.isLoading || !artistQuery.hasData) {
const ShimmerArtistProfile();
if (artistsQuery.isLoading || !artistsQuery.hasData) { } else if (artistQuery.hasError) {
return const ShimmerArtistProfile(); return Center(child: Text(artistQuery.error.toString()));
} else if (artistsQuery.hasError) { }
return Center( return CustomScrollView(
child: Text(artistsQuery.error.toString()), controller: scrollController,
); slivers: [
} SliverToBoxAdapter(
final data = artistsQuery.data!;
final blacklist = ref.watch(BlackListNotifier.provider);
final isBlackListed = blacklist.contains(
BlacklistedElement.artist(artistId, data.name!),
);
return InterScrollbar(
controller: parentScrollController,
child: SingleChildScrollView(
controller: parentScrollController,
child: SafeArea( child: SafeArea(
child: Column( bottom: false,
crossAxisAlignment: CrossAxisAlignment.start, child: ArtistPageHeader(artistId: artistId),
children: [ ),
Wrap( ),
crossAxisAlignment: WrapCrossAlignment.center, const SliverGap(50),
runAlignment: WrapAlignment.center, ArtistPageTopTracks(artistId: artistId),
children: [ const SliverGap(50),
const SizedBox(width: 50), SliverToBoxAdapter(child: ArtistAlbumList(artistId)),
Padding( const SliverGap(20),
padding: const EdgeInsets.all(16), SliverPadding(
child: CircleAvatar( padding: const EdgeInsets.all(8.0),
radius: avatarWidth, sliver: SliverToBoxAdapter(
backgroundImage: UniversalImage.imageProvider( child: Text(
TypeConversionUtils.image_X_UrlString( context.l10n.fans_also_like,
data.images, style: theme.textTheme.headlineSmall,
placeholder: ImagePlaceholder.artist,
),
),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius:
BorderRadius.circular(50)),
child: Text(
data.type!.toUpperCase(),
style: chipTextVariant.copyWith(
color: Colors.white,
),
),
),
if (isBlackListed) ...[
const SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.red[400],
borderRadius:
BorderRadius.circular(50)),
child: Text(
context.l10n.blacklisted,
style: chipTextVariant.copyWith(
color: Colors.white,
),
),
),
]
],
),
Text(
data.name!,
style: mediaQuery.smAndDown
? textTheme.headlineSmall
: textTheme.headlineMedium,
),
Text(
context.l10n.followers(
PrimitiveUtils.toReadableNumber(
data.followers!.total!.toDouble(),
),
),
style: textTheme.bodyMedium?.copyWith(
fontWeight: mediaQuery.mdAndUp
? FontWeight.bold
: null,
),
),
const SizedBox(height: 20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (auth != null)
HookBuilder(
builder: (context) {
final isFollowingQuery = useQueries
.artist
.doIFollow(ref, artistId);
final followUnfollow =
useCallback(() async {
try {
isFollowingQuery.data!
? await spotify.me.unfollow(
FollowingType.artist,
[artistId],
)
: await spotify.me.follow(
FollowingType.artist,
[artistId],
);
await isFollowingQuery.refresh();
queryClient
.refreshInfiniteQueryAllPages(
"user-following-artists");
} finally {
queryClient.refreshQuery(
"user-follows-artists-query/$artistId",
);
}
}, [isFollowingQuery]);
if (isFollowingQuery.isLoading ||
!isFollowingQuery.hasData) {
return const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(),
);
}
if (isFollowingQuery.data!) {
return OutlinedButton(
onPressed: followUnfollow,
child:
Text(context.l10n.following),
);
}
return FilledButton(
onPressed: followUnfollow,
child: Text(context.l10n.follow),
);
},
),
const SizedBox(width: 5),
IconButton(
tooltip:
context.l10n.add_artist_to_blacklist,
icon: Icon(
SpotubeIcons.userRemove,
color: !isBlackListed
? Colors.red[400]
: Colors.white,
),
style: IconButton.styleFrom(
backgroundColor: isBlackListed
? Colors.red[400]
: null,
),
onPressed: () async {
if (isBlackListed) {
ref
.read(BlackListNotifier
.provider.notifier)
.remove(
BlacklistedElement.artist(
data.id!, data.name!),
);
} else {
ref
.read(BlackListNotifier
.provider.notifier)
.add(
BlacklistedElement.artist(
data.id!, data.name!),
);
}
},
),
IconButton(
icon: const Icon(SpotubeIcons.share),
onPressed: () async {
if (data.externalUrls?.spotify !=
null) {
await Clipboard.setData(
ClipboardData(
text: data.externalUrls!.spotify!,
),
);
}
if (!context.mounted) return;
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
context.l10n.artist_url_copied,
textAlign: TextAlign.center,
),
),
);
},
)
],
)
],
),
),
],
),
const SizedBox(height: 50),
HookBuilder(
builder: (context) {
final topTracksQuery = useQueries.artist.topTracksOf(
ref,
artistId,
);
final isPlaylistPlaying = playlist.containsTracks(
topTracksQuery.data ?? <Track>[],
);
if (topTracksQuery.isLoading ||
!topTracksQuery.hasData) {
return const CircularProgressIndicator();
} else if (topTracksQuery.hasError) {
return Center(
child: Text(topTracksQuery.error.toString()),
);
}
final topTracks = topTracksQuery.data!;
void playPlaylist(List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first;
if (!isPlaylistPlaying) {
playlistNotifier.load(
tracks,
initialIndex: tracks.indexWhere(
(s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playlistNotifier.jumpToTrack(currentTrack);
}
}
return Column(
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
context.l10n.top_tracks,
style: theme.textTheme.headlineSmall,
),
),
if (!isPlaylistPlaying)
IconButton(
icon: const Icon(
SpotubeIcons.queueAdd,
),
onPressed: () {
playlistNotifier
.addTracks(topTracks.toList());
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
context.l10n.added_to_queue(
topTracks.length,
),
textAlign: TextAlign.center,
),
),
);
},
),
const SizedBox(width: 5),
IconButton(
icon: Icon(
isPlaylistPlaying
? SpotubeIcons.stop
: SpotubeIcons.play,
color: Colors.white,
),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.primary,
),
onPressed: () =>
playPlaylist(topTracks.toList()),
)
],
),
...topTracks.mapIndexed((i, track) {
return TrackTile(
index: i,
track: track,
onTap: () async {
playPlaylist(
topTracks.toList(),
currentTrack: track,
);
},
);
}),
],
);
},
),
const SizedBox(height: 50),
ArtistAlbumList(artistId),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
context.l10n.fans_also_like,
style: theme.textTheme.headlineSmall,
),
),
const SizedBox(height: 10),
HookBuilder(
builder: (context) {
final relatedArtists =
useQueries.artist.relatedArtistsOf(
ref,
artistId,
);
if (relatedArtists.isLoading ||
!relatedArtists.hasData) {
return const CircularProgressIndicator();
} else if (relatedArtists.hasError) {
return Center(
child: Text(relatedArtists.error.toString()),
);
}
return Center(
child: Wrap(
spacing: 20,
runSpacing: 20,
children: relatedArtists.data!
.map((artist) => ArtistCard(artist))
.toList(),
),
);
},
),
],
), ),
), ),
), ),
); SliverSafeArea(
}, sliver: ArtistPageRelatedArtists(artistId: artistId),
), ),
if (artistQuery.data != null)
SliverSafeArea(
top: false,
sliver: SliverToBoxAdapter(
child: ArtistPageFooter(artist: artistQuery.data!),
),
),
],
);
}),
), ),
); );
} }

View File

@ -0,0 +1,93 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ArtistPageFooter extends HookConsumerWidget {
final Artist artist;
const ArtistPageFooter({Key? key, required this.artist}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final artistImage = TypeConversionUtils.image_X_UrlString(
artist.images,
placeholder: ImagePlaceholder.artist,
);
final summary = useQueries.artist.wikipediaSummary(artist);
if (summary.hasError || !summary.hasData) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.all(16),
padding: mediaQuery.smAndDown
? const EdgeInsets.all(20)
: const EdgeInsets.all(30),
constraints: const BoxConstraints(minHeight: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
image: DecorationImage(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.5),
BlendMode.darken,
),
image: UniversalImage.imageProvider(
summary.data!.thumbnail?.source_ ?? artistImage,
height: summary.data!.thumbnail?.height.toDouble(),
width: summary.data!.thumbnail?.width.toDouble(),
),
fit: BoxFit.cover,
alignment: Alignment.center,
),
),
alignment: Alignment.center,
child: RichText(
text: TextSpan(
style: textTheme.bodyLarge?.copyWith(
color: Colors.white,
),
children: [
// icon
const WidgetSpan(
child: Icon(
SpotubeIcons.wikipedia,
color: Colors.white,
size: 30,
),
),
TextSpan(
text: " Wikipedia",
style: textTheme.titleLarge?.copyWith(
color: Colors.white,
),
),
const TextSpan(text: '\n\n'),
TextSpan(
text: summary.data!.extract,
),
TextSpan(
text: '\n...read more at wikipedia',
style: textTheme.bodyLarge?.copyWith(
color: Colors.lightBlue[300],
decoration: TextDecoration.underline,
decorationColor: Colors.lightBlue[300],
),
recognizer: TapGestureRecognizer()
..onTap = () async {
await launchUrlString(
"http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}",
);
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,257 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class ArtistPageHeader extends HookConsumerWidget {
final String artistId;
const ArtistPageHeader({Key? key, required this.artistId}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final queryClient = useQueryClient();
final artistQuery = useQueries.artist.get(ref, artistId);
final artist = artistQuery.data;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context);
final theme = Theme.of(context);
final ThemeData(:textTheme) = theme;
final chipTextVariant = useBreakpointValue(
xs: textTheme.bodySmall,
sm: textTheme.bodySmall,
md: textTheme.bodyMedium,
lg: textTheme.bodyLarge,
xl: textTheme.titleSmall,
xxl: textTheme.titleMedium,
);
if (artist == null) {
return const SizedBox.shrink();
}
final spotify = ref.read(spotifyProvider);
final auth = ref.watch(AuthenticationNotifier.provider);
final blacklist = ref.watch(BlackListNotifier.provider);
final isBlackListed = blacklist.contains(
BlacklistedElement.artist(artistId, artist.name!),
);
final image = TypeConversionUtils.image_X_UrlString(
artist.images,
placeholder: ImagePlaceholder.artist,
);
return LayoutBuilder(
builder: (context, constrains) {
return Center(
child: Flex(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: constrains.smAndDown
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
direction: constrains.smAndDown ? Axis.vertical : Axis.horizontal,
children: [
DecoratedBox(
decoration: BoxDecoration(
boxShadow: kElevationToShadow[2],
borderRadius: BorderRadius.circular(35),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(35),
child: UniversalImage(
path: image,
width: 250,
height: 250,
fit: BoxFit.cover,
),
),
),
const Gap(20),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(50)),
child: Text(
artist.type!.toUpperCase(),
style: chipTextVariant.copyWith(
color: Colors.white,
),
),
),
if (isBlackListed) ...[
const SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.red[400],
borderRadius: BorderRadius.circular(50)),
child: Text(
context.l10n.blacklisted,
style: chipTextVariant.copyWith(
color: Colors.white,
),
),
),
]
],
),
Text(
artist.name!,
style: mediaQuery.smAndDown
? textTheme.headlineSmall
: textTheme.headlineMedium,
),
Text(
context.l10n.followers(
PrimitiveUtils.toReadableNumber(
artist.followers!.total!.toDouble(),
),
),
style: textTheme.bodyMedium?.copyWith(
fontWeight: mediaQuery.mdAndUp ? FontWeight.bold : null,
),
),
const Gap(20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (auth != null)
HookBuilder(
builder: (context) {
final isFollowingQuery =
useQueries.artist.doIFollow(ref, artistId);
final followUnfollow = useCallback(() async {
try {
isFollowingQuery.data!
? await spotify.me.unfollow(
FollowingType.artist,
[artistId],
)
: await spotify.me.follow(
FollowingType.artist,
[artistId],
);
await isFollowingQuery.refresh();
queryClient.refreshInfiniteQueryAllPages(
"user-following-artists");
} finally {
queryClient.refreshQuery(
"user-follows-artists-query/$artistId",
);
}
}, [isFollowingQuery]);
if (isFollowingQuery.isLoading ||
!isFollowingQuery.hasData) {
return const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(),
);
}
if (isFollowingQuery.data!) {
return OutlinedButton(
onPressed: followUnfollow,
child: Text(context.l10n.following),
);
}
return FilledButton(
onPressed: followUnfollow,
child: Text(context.l10n.follow),
);
},
),
const SizedBox(width: 5),
IconButton(
tooltip: context.l10n.add_artist_to_blacklist,
icon: Icon(
SpotubeIcons.userRemove,
color:
!isBlackListed ? Colors.red[400] : Colors.white,
),
style: IconButton.styleFrom(
backgroundColor:
isBlackListed ? Colors.red[400] : null,
),
onPressed: () async {
if (isBlackListed) {
ref
.read(BlackListNotifier.provider.notifier)
.remove(
BlacklistedElement.artist(
artist.id!, artist.name!),
);
} else {
ref.read(BlackListNotifier.provider.notifier).add(
BlacklistedElement.artist(
artist.id!, artist.name!),
);
}
},
),
IconButton(
icon: const Icon(SpotubeIcons.share),
onPressed: () async {
if (artist.externalUrls?.spotify != null) {
await Clipboard.setData(
ClipboardData(
text: artist.externalUrls!.spotify!,
),
);
}
if (!context.mounted) return;
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
context.l10n.artist_url_copied,
textAlign: TextAlign.center,
),
),
);
},
)
],
)
],
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/artist/artist_card.dart';
import 'package:spotube/services/queries/queries.dart';
class ArtistPageRelatedArtists extends HookConsumerWidget {
final String artistId;
const ArtistPageRelatedArtists({
Key? key,
required this.artistId,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final relatedArtists = useQueries.artist.relatedArtistsOf(
ref,
artistId,
);
if (relatedArtists.isLoading || !relatedArtists.hasData) {
return const SliverToBoxAdapter(
child: Center(child: CircularProgressIndicator()));
} else if (relatedArtists.hasError) {
return SliverToBoxAdapter(
child: Center(
child: Text(relatedArtists.error.toString()),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
sliver: SliverGrid.builder(
itemCount: relatedArtists.data!.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: 250,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 0.8,
),
itemBuilder: (context, index) {
final artist = relatedArtists.data!.elementAt(index);
return ArtistCard(artist);
},
),
);
}
}

View File

@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/queries/queries.dart';
class ArtistPageTopTracks extends HookConsumerWidget {
final String artistId;
const ArtistPageTopTracks({Key? key, required this.artistId})
: super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final topTracksQuery = useQueries.artist.topTracksOf(
ref,
artistId,
);
final isPlaylistPlaying = playlist.containsTracks(
topTracksQuery.data ?? <Track>[],
);
if (topTracksQuery.isLoading || !topTracksQuery.hasData) {
return const SliverToBoxAdapter(
child: Center(child: CircularProgressIndicator()),
);
} else if (topTracksQuery.hasError) {
return SliverToBoxAdapter(
child: Center(
child: Text(topTracksQuery.error.toString()),
),
);
}
final topTracks = topTracksQuery.data!;
void playPlaylist(List<Track> tracks, {Track? currentTrack}) async {
currentTrack ??= tracks.first;
if (!isPlaylistPlaying) {
playlistNotifier.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playlistNotifier.jumpToTrack(currentTrack);
}
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Row(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
context.l10n.top_tracks,
style: theme.textTheme.headlineSmall,
),
),
if (!isPlaylistPlaying)
IconButton(
icon: const Icon(
SpotubeIcons.queueAdd,
),
onPressed: () {
playlistNotifier.addTracks(topTracks.toList());
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
context.l10n.added_to_queue(
topTracks.length,
),
textAlign: TextAlign.center,
),
),
);
},
),
const SizedBox(width: 5),
IconButton(
icon: Icon(
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
color: Colors.white,
),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
),
onPressed: () => playPlaylist(topTracks.toList()),
)
],
),
),
SliverList.builder(
itemCount: topTracks.length,
itemBuilder: (context, index) {
final track = topTracks.elementAt(index);
return TrackTile(
index: index,
track: track,
onTap: () async {
playPlaylist(
topTracks.toList(),
currentTrack: track,
);
},
);
},
),
],
);
}
}

View File

@ -70,7 +70,8 @@ class GenrePage extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
ExpandableSearchField( ExpandableSearchField(
isFiltering: isFiltering, isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
searchController: searchController, searchController: searchController,
searchFocus: searchFocus, searchFocus: searchFocus,
), ),
@ -103,10 +104,11 @@ class GenrePage extends HookConsumerWidget {
top: 0, top: 0,
right: 10, right: 10,
child: ExpandableSearchButton( child: ExpandableSearchButton(
isFiltering: isFiltering, isFiltering: isFiltering.value,
searchFocus: searchFocus, searchFocus: searchFocus,
icon: const Icon(SpotubeIcons.search), icon: const Icon(SpotubeIcons.search),
onPressed: (value) { onPressed: (value) {
isFiltering.value = value;
if (isFiltering.value) { if (isFiltering.value) {
scrollController.animateTo( scrollController.animateTo(
0, 0,

View File

@ -46,47 +46,64 @@ class PersonalizedPage extends HookConsumerWidget {
[newReleases.pages], [newReleases.pages],
); );
return ListView( return CustomScrollView(
controller: controller, controller: controller,
children: [ slivers: [
if (!featuredPlaylistsQuery.hasPageData && SliverList.list(
!featuredPlaylistsQuery.isLoadingNextPage) children: [
const ShimmerCategories() AnimatedSwitcher(
else duration: const Duration(milliseconds: 300),
HorizontalPlaybuttonCardView<PlaylistSimple>( child: !featuredPlaylistsQuery.hasPageData &&
items: playlists.toList(), !featuredPlaylistsQuery.isLoadingNextPage
title: Text(context.l10n.featured), ? const ShimmerCategories()
isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage, : HorizontalPlaybuttonCardView<PlaylistSimple>(
hasNextPage: featuredPlaylistsQuery.hasNextPage, items: playlists.toList(),
onFetchMore: featuredPlaylistsQuery.fetchNext, title: Text(context.l10n.featured),
isLoadingNextPage:
featuredPlaylistsQuery.isLoadingNextPage,
hasNextPage: featuredPlaylistsQuery.hasNextPage,
onFetchMore: featuredPlaylistsQuery.fetchNext,
),
),
if (auth != null)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: newReleases.hasPageData &&
userArtistsQuery.hasData &&
!newReleases.isLoadingNextPage
? HorizontalPlaybuttonCardView<Album>(
items: albums,
title: Text(context.l10n.new_releases),
isLoadingNextPage: newReleases.isLoadingNextPage,
hasNextPage: newReleases.hasNextPage,
onFetchMore: newReleases.fetchNext,
)
: const ShimmerCategories(),
),
],
),
SliverSafeArea(
sliver: SliverList.builder(
itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0,
itemBuilder: (context, index) {
final item = madeForUser.data?["content"]?["items"]?[index];
final playlists = item["content"]?["items"]
?.where((itemL2) => itemL2["type"] == "playlist")
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
.toList()
.cast<PlaylistSimple>() ??
<PlaylistSimple>[];
if (playlists.isEmpty) return const SizedBox.shrink();
return HorizontalPlaybuttonCardView<PlaylistSimple>(
items: playlists,
title: Text(item["name"] ?? ""),
hasNextPage: false,
isLoadingNextPage: false,
onFetchMore: () {},
);
},
), ),
if (auth != null && ),
newReleases.hasPageData &&
userArtistsQuery.hasData &&
!newReleases.isLoadingNextPage)
HorizontalPlaybuttonCardView<Album>(
items: albums,
title: Text(context.l10n.new_releases),
isLoadingNextPage: newReleases.isLoadingNextPage,
hasNextPage: newReleases.hasNextPage,
onFetchMore: newReleases.fetchNext,
),
...?madeForUser.data?["content"]?["items"]?.map((item) {
final playlists = item["content"]?["items"]
?.where((itemL2) => itemL2["type"] == "playlist")
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
.toList()
.cast<PlaylistSimple>() ??
<PlaylistSimple>[];
if (playlists.isEmpty) return const SizedBox.shrink();
return HorizontalPlaybuttonCardView<PlaylistSimple>(
items: playlists,
title: Text(item["name"] ?? ""),
hasNextPage: false,
isLoadingNextPage: false,
onFetchMore: () {},
);
})
], ],
); );
} }

View File

@ -242,267 +242,284 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
}, },
); );
final controller = useScrollController();
return Scaffold( return Scaffold(
appBar: PageWindowTitleBar( appBar: PageWindowTitleBar(
leading: const BackButton(), leading: const BackButton(),
title: Text(context.l10n.generate_playlist), title: Text(context.l10n.generate_playlist),
centerTitle: true, centerTitle: true,
), ),
body: Center( body: Scrollbar(
child: ConstrainedBox( controller: controller,
constraints: BoxConstraints(maxWidth: Breakpoints.lg), child: Center(
child: SliderTheme( child: ConstrainedBox(
data: const SliderThemeData( constraints: BoxConstraints(maxWidth: Breakpoints.lg),
overlayShape: RoundSliderOverlayShape(), child: SliderTheme(
), data: const SliderThemeData(
child: SafeArea( overlayShape: RoundSliderOverlayShape(),
child: LayoutBuilder(builder: (context, constrains) { ),
return ListView( child: SafeArea(
padding: const EdgeInsets.all(16), child: LayoutBuilder(builder: (context, constrains) {
children: [ return ScrollConfiguration(
ValueListenableBuilder( behavior: ScrollConfiguration.of(context)
valueListenable: limit, .copyWith(scrollbars: false),
builder: (context, value, child) { child: ListView(
return Column( controller: controller,
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(16),
children: [ children: [
Text( ValueListenableBuilder(
context.l10n.number_of_tracks_generate, valueListenable: limit,
style: textTheme.titleMedium, builder: (context, value, child) {
), return Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Text(
width: 40, context.l10n.number_of_tracks_generate,
height: 40, style: textTheme.titleMedium,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: Text(
value.round().toString(),
style: textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.primaryContainer,
),
),
), ),
Expanded( Row(
child: Slider( children: [
value: value.toDouble(), Container(
min: 10, width: 40,
max: 100, height: 40,
divisions: 9, alignment: Alignment.center,
label: value.round().toString(), decoration: BoxDecoration(
onChanged: (value) { color: theme.colorScheme.primary,
limit.value = value.round(); shape: BoxShape.circle,
}, ),
), child: Text(
value.round().toString(),
style: textTheme.bodyLarge?.copyWith(
color: theme
.colorScheme.primaryContainer,
),
),
),
Expanded(
child: Slider(
value: value.toDouble(),
min: 10,
max: 100,
divisions: 9,
label: value.round().toString(),
onChanged: (value) {
limit.value = value.round();
},
),
)
],
) )
], ],
) );
], },
); ),
}, const SizedBox(height: 16),
), if (constrains.mdAndUp)
const SizedBox(height: 16), Row(
if (constrains.mdAndUp) crossAxisAlignment: CrossAxisAlignment.start,
Row( children: [
crossAxisAlignment: CrossAxisAlignment.start, Expanded(
children: [ child: countrySelector,
Expanded( ),
child: countrySelector, const SizedBox(width: 16),
), Expanded(
const SizedBox(width: 16), child: genreSelector,
Expanded( ),
child: genreSelector, ],
), )
else ...[
countrySelector,
const SizedBox(height: 16),
genreSelector,
], ],
) const SizedBox(height: 16),
else ...[ if (constrains.mdAndUp)
countrySelector, Row(
const SizedBox(height: 16), crossAxisAlignment: CrossAxisAlignment.start,
genreSelector, children: [
], Expanded(
const SizedBox(height: 16), child: artistAutoComplete,
if (constrains.mdAndUp) ),
Row( const SizedBox(width: 16),
crossAxisAlignment: CrossAxisAlignment.start, Expanded(
children: [ child: tracksAutocomplete,
Expanded( ),
child: artistAutoComplete, ],
), )
const SizedBox(width: 16), else ...[
Expanded( artistAutoComplete,
child: tracksAutocomplete, const SizedBox(height: 16),
), tracksAutocomplete,
], ],
) const SizedBox(height: 16),
else ...[ RecommendationAttributeDials(
artistAutoComplete, title: Text(context.l10n.acousticness),
const SizedBox(height: 16), values: acousticness.value,
tracksAutocomplete, onChanged: (value) {
], acousticness.value = value;
const SizedBox(height: 16), },
RecommendationAttributeDials( ),
title: Text(context.l10n.acousticness), RecommendationAttributeDials(
values: acousticness.value, title: Text(context.l10n.danceability),
onChanged: (value) { values: danceability.value,
acousticness.value = value; onChanged: (value) {
}, danceability.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.energy),
values: energy.value,
onChanged: (value) {
energy.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.instrumentalness),
values: instrumentalness.value,
onChanged: (value) {
instrumentalness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.liveness),
values: liveness.value,
onChanged: (value) {
liveness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.loudness),
values: loudness.value,
onChanged: (value) {
loudness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.speechiness),
values: speechiness.value,
onChanged: (value) {
speechiness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.valence),
values: valence.value,
onChanged: (value) {
valence.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.popularity),
values: popularity.value,
base: 100,
onChanged: (value) {
popularity.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.key),
values: key.value,
base: 11,
onChanged: (value) {
key.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.duration),
values: (
max: durationMs.value.max / 1000,
target: durationMs.value.target / 1000,
min: durationMs.value.min / 1000,
),
onChanged: (value) {
durationMs.value = (
max: value.max * 1000,
target: value.target * 1000,
min: value.min * 1000,
);
},
presets: {
context.l10n.short: (min: 50, target: 90, max: 120),
context.l10n.medium: (
min: 120,
target: 180,
max: 200
),
context.l10n.long: (min: 480, target: 560, max: 640)
},
),
RecommendationAttributeFields(
title: Text(context.l10n.tempo),
values: tempo.value,
onChanged: (value) {
tempo.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.mode),
values: mode.value,
onChanged: (value) {
mode.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.time_signature),
values: timeSignature.value,
onChanged: (value) {
timeSignature.value = value;
},
),
const SizedBox(height: 20),
FilledButton.icon(
icon: const Icon(SpotubeIcons.magic),
label: Text(context.l10n.generate_playlist),
onPressed: artists.value.isEmpty &&
tracks.value.isEmpty &&
genres.value.isEmpty
? null
: () {
final PlaylistGenerateResultRouteState
routeState = (
seeds: (
artists: artists.value
.map((a) => a.id!)
.toList(),
tracks: tracks.value
.map((t) => t.id!)
.toList(),
genres: genres.value
),
market: market.value,
limit: limit.value,
parameters: (
acousticness: acousticness.value,
danceability: danceability.value,
energy: energy.value,
instrumentalness: instrumentalness.value,
liveness: liveness.value,
loudness: loudness.value,
speechiness: speechiness.value,
valence: valence.value,
popularity: popularity.value,
key: key.value,
duration_ms: durationMs.value,
tempo: tempo.value,
mode: mode.value,
time_signature: timeSignature.value,
)
);
GoRouter.of(context).push(
"/library/generate/result",
extra: routeState,
);
},
),
],
), ),
RecommendationAttributeDials( );
title: Text(context.l10n.danceability), }),
values: danceability.value, ),
onChanged: (value) {
danceability.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.energy),
values: energy.value,
onChanged: (value) {
energy.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.instrumentalness),
values: instrumentalness.value,
onChanged: (value) {
instrumentalness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.liveness),
values: liveness.value,
onChanged: (value) {
liveness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.loudness),
values: loudness.value,
onChanged: (value) {
loudness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.speechiness),
values: speechiness.value,
onChanged: (value) {
speechiness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.valence),
values: valence.value,
onChanged: (value) {
valence.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.popularity),
values: popularity.value,
base: 100,
onChanged: (value) {
popularity.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.key),
values: key.value,
base: 11,
onChanged: (value) {
key.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.duration),
values: (
max: durationMs.value.max / 1000,
target: durationMs.value.target / 1000,
min: durationMs.value.min / 1000,
),
onChanged: (value) {
durationMs.value = (
max: value.max * 1000,
target: value.target * 1000,
min: value.min * 1000,
);
},
presets: {
context.l10n.short: (min: 50, target: 90, max: 120),
context.l10n.medium: (min: 120, target: 180, max: 200),
context.l10n.long: (min: 480, target: 560, max: 640)
},
),
RecommendationAttributeFields(
title: Text(context.l10n.tempo),
values: tempo.value,
onChanged: (value) {
tempo.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.mode),
values: mode.value,
onChanged: (value) {
mode.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.time_signature),
values: timeSignature.value,
onChanged: (value) {
timeSignature.value = value;
},
),
const SizedBox(height: 20),
FilledButton.icon(
icon: const Icon(SpotubeIcons.magic),
label: Text(context.l10n.generate_playlist),
onPressed: artists.value.isEmpty &&
tracks.value.isEmpty &&
genres.value.isEmpty
? null
: () {
final PlaylistGenerateResultRouteState
routeState = (
seeds: (
artists:
artists.value.map((a) => a.id!).toList(),
tracks:
tracks.value.map((t) => t.id!).toList(),
genres: genres.value
),
market: market.value,
limit: limit.value,
parameters: (
acousticness: acousticness.value,
danceability: danceability.value,
energy: energy.value,
instrumentalness: instrumentalness.value,
liveness: liveness.value,
loudness: loudness.value,
speechiness: speechiness.value,
valence: valence.value,
popularity: popularity.value,
key: key.value,
duration_ms: durationMs.value,
tempo: tempo.value,
mode: mode.value,
time_signature: timeSignature.value,
)
);
GoRouter.of(context).push(
"/library/generate/result",
extra: routeState,
);
},
),
],
);
}),
), ),
), ),
), ),

View File

@ -163,6 +163,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
context: context, context: context,
builder: (context) => builder: (context) =>
PlaylistAddTrackDialog( PlaylistAddTrackDialog(
openFromPlaylist: null,
tracks: selectedTracks.value tracks: selectedTracks.value
.map( .map(
(e) => generatedPlaylist.data! (e) => generatedPlaylist.data!

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
@ -32,6 +33,7 @@ class MiniLyricsPage extends HookConsumerWidget {
final areaActive = useState(false); final areaActive = useState(false);
final hoverMode = useState(true); final hoverMode = useState(true);
final showLyrics = useState(true);
useEffect(() { useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
@ -82,17 +84,41 @@ class MiniLyricsPage extends HookConsumerWidget {
child: Sidebar.brandLogo(), child: Sidebar.brandLogo(),
), ),
const Spacer(), const Spacer(),
SizedBox( if (showLyrics.value)
height: 30, SizedBox(
child: TabBar( height: 30,
tabs: [ child: TabBar(
Tab(text: context.l10n.synced), tabs: [
Tab(text: context.l10n.plain), Tab(text: context.l10n.synced),
], Tab(text: context.l10n.plain),
isScrollable: true, ],
isScrollable: true,
),
), ),
),
const Spacer(), const Spacer(),
IconButton(
tooltip: context.l10n.lyrics,
icon: showLyrics.value
? const Icon(SpotubeIcons.lyrics)
: const Icon(SpotubeIcons.lyricsOff),
style: ButtonStyle(
foregroundColor: showLyrics.value
? MaterialStateProperty.all(
theme.colorScheme.primary)
: null,
),
onPressed: () async {
showLyrics.value = !showLyrics.value;
areaActive.value = true;
hoverMode.value = false;
await DesktopTools.window.setSize(
showLyrics.value
? const Size(400, 500)
: const Size(400, 150),
);
},
),
IconButton( IconButton(
tooltip: context.l10n.show_hide_ui_on_hover, tooltip: context.l10n.show_hide_ui_on_hover,
icon: hoverMode.value icon: hoverMode.value
@ -105,9 +131,7 @@ class MiniLyricsPage extends HookConsumerWidget {
: null, : null,
), ),
onPressed: () async { onPressed: () async {
if (!hoverMode.value == true) { areaActive.value = true;
areaActive.value = true;
}
hoverMode.value = !hoverMode.value; hoverMode.value = !hoverMode.value;
}, },
), ),
@ -150,22 +174,25 @@ class MiniLyricsPage extends HookConsumerWidget {
playlistQueue.activeTrack!.name!, playlistQueue.activeTrack!.name!,
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
Expanded( if (showLyrics.value)
child: TabBarView( Expanded(
children: [ child: TabBarView(
SyncedLyrics( children: [
palette: PaletteColor(theme.colorScheme.background, 0), SyncedLyrics(
isModal: true, palette: PaletteColor(theme.colorScheme.background, 0),
defaultTextZoom: 65, isModal: true,
), defaultTextZoom: 65,
PlainLyrics( ),
palette: PaletteColor(theme.colorScheme.background, 0), PlainLyrics(
isModal: true, palette: PaletteColor(theme.colorScheme.background, 0),
defaultTextZoom: 65, isModal: true,
), defaultTextZoom: 65,
], ),
), ],
), ),
)
else
const Gap(20),
AnimatedCrossFade( AnimatedCrossFade(
crossFadeState: areaActive.value crossFadeState: areaActive.value
? CrossFadeState.showFirst ? CrossFadeState.showFirst

View File

@ -112,7 +112,7 @@ class SyncedLyrics extends HookConsumerWidget {
final lyricSlice = lyricValue.lyrics[index]; final lyricSlice = lyricValue.lyrics[index];
final isActive = lyricSlice.time.inSeconds == currentTime; final isActive = lyricSlice.time.inSeconds == currentTime;
if (isActive && isUnSyncLyric == true) { if (isActive) {
controller.scrollToIndex( controller.scrollToIndex(
index, index,
preferPosition: AutoScrollPosition.middle, preferPosition: AutoScrollPosition.middle,

View File

@ -0,0 +1,48 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/tracks_view/track_view.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class LikedPlaylistPage extends HookConsumerWidget {
final PlaylistSimple playlist;
const LikedPlaylistPage({
Key? key,
required this.playlist,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final likedTracks = useQueries.playlist.likedTracksQuery(ref);
final tracks = likedTracks.data ?? <Track>[];
return InheritedTrackView(
collectionId: playlist.id!,
image: TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
pagination: PaginationProps(
hasNextPage: false,
isLoading: false,
onFetchMore: () {},
onFetchAll: () async {
return tracks.toList();
},
onRefresh: () async {
await likedTracks.refresh();
},
),
title: playlist.name!,
description: playlist.description,
tracks: tracks,
routePath: '/playlist/${playlist.id}',
isLiked: false,
shareUrl: "",
onHeart: null,
child: const TrackView(),
);
}
}

View File

@ -1,178 +1,82 @@
import 'package:flutter/services.dart'; import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistView extends HookConsumerWidget { class PlaylistPage extends HookConsumerWidget {
final logger = getLogger(PlaylistView); final PlaylistSimple playlist;
final PlaylistSimple playlistSimple; const PlaylistPage({
PlaylistView(this.playlistSimple, {Key? key}) : super(key: key); Key? key,
required this.playlist,
}) : super(key: key);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final proxyPlaylist = ref.watch(ProxyPlaylistNotifier.provider); final spotify = ref.watch(spotifyProvider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!);
final mediaQuery = MediaQuery.of(context); final tracks = useMemoized(
() {
final meSnapshot = useQueries.user.me(ref); return tracksQuery.pages.expand((page) => page).toList();
},
final playlistQuery = useQueries.playlist.byId(ref, playlistSimple.id!); [tracksQuery.pages],
final playlist = playlistQuery.data ?? playlistSimple;
final playlistTrackSnapshot =
useQueries.playlist.tracksOfQuery(ref, playlist.id!);
final likedTracksSnapshot = useQueries.playlist.likedTracksQuery(ref);
final tracksSnapshot = playlist.id! == "user-liked-tracks"
? likedTracksSnapshot
: playlistTrackSnapshot;
final isPlaylistPlaying = useMemoized(
() => proxyPlaylist.collections.contains(playlist.id!),
[proxyPlaylist, playlist],
); );
final titleImage = useMemoized( final me = useQueries.user.me(ref);
() => TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
[playlist.images]);
final playlistTrackPlaying = useMemoized( final isLikedQuery = useQueries.playlist.doesUserFollow(
() => ref,
tracksSnapshot.data playlist.id!,
?.any((s) => s.id! == proxyPlaylist.activeTrack?.id!) == me.data?.id ?? '',
true &&
proxyPlaylist.activeTrack is SpotubeTrack,
[proxyPlaylist.activeTrack, tracksSnapshot.data],
); );
final playPlaylist = useCallback(( final togglePlaylistLike = useMutations.playlist.toggleFavorite(
List<Track> tracks, ref,
WidgetRef ref, { playlist.id!,
Track? currentTrack, refreshQueries: [
}) async { isLikedQuery.key,
final playback = ref.read(ProxyPlaylistNotifier.notifier); ],
final sortBy = ref.read(trackCollectionSortState(playlist.id!)); );
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks);
if (!isPlaylistPlaying) {
playback.addCollection(playlist.id!); // for enabling loading indicator
await playback.load(
sortedTracks,
initialIndex:
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
playback.addCollection(playlist.id!);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != proxyPlaylist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}, [proxyPlaylist, playlist]);
final ownPlaylist = return InheritedTrackView(
playlist.owner?.id != null && playlist.owner?.id == meSnapshot.data?.id; collectionId: playlist.id!,
image: TypeConversionUtils.image_X_UrlString(
return TrackCollectionView( playlist.images,
id: playlist.id!, placeholder: ImagePlaceholder.collection,
playingState: isPlaylistPlaying && playlistTrackPlaying ),
? PlayButtonState.playing pagination: PaginationProps.fromQuery(
: isPlaylistPlaying && !playlistTrackPlaying tracksQuery,
? PlayButtonState.loading onFetchAll: () {
: PlayButtonState.notPlaying, return tracksQuery.fetchAllTracks(
title: playlist.name!, getAllTracks: () async {
titleImage: titleImage, final res = await spotify.playlists
tracksSnapshot: tracksSnapshot, .getTracksByPlaylistId(playlist.id!)
description: playlist.description, .all();
isOwned: ownPlaylist, return res.toList();
onPlay: ([track]) async { },
if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying || (isPlaylistPlaying && track != null)) {
await playPlaylist(
tracksSnapshot.data!,
ref,
currentTrack: track,
);
} else {
await playlistNotifier
.removeTracks(tracksSnapshot.data!.map((e) => e.id!));
}
}
},
onAddToQueue: () {
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
playlistNotifier.addTracks(tracksSnapshot.data!);
playlistNotifier.addCollection(playlist.id!);
}
},
bottomSpace: mediaQuery.mdAndDown,
showShare: playlist.id != "user-liked-tracks",
routePath: "/playlist/${playlist.id}",
onShare: () {
final data = "https://open.spotify.com/playlist/${playlist.id}";
Clipboard.setData(
ClipboardData(text: data),
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
"Copied $data to clipboard",
textAlign: TextAlign.center,
),
),
); );
});
},
heartBtn: PlaylistHeartButton(
playlist: playlist,
icon: ownPlaylist ? SpotubeIcons.trash : null,
onData: (data) {
GoRouter.of(context).pop();
}, },
), ),
onShuffledPlay: ([track]) { title: playlist.name!,
final tracks = [...?tracksSnapshot.data]..shuffle(); description: playlist.description,
tracks: tracks,
if (tracksSnapshot.hasData) { routePath: '/playlist/${playlist.id}',
if (!isPlaylistPlaying) { isLiked: isLikedQuery.data ?? false,
playPlaylist( shareUrl: playlist.externalUrls?.spotify ?? "",
tracks, onHeart: () async {
ref, if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) {
currentTrack: track, return;
);
} else if (isPlaylistPlaying && track != null) {
playPlaylist(
tracks,
ref,
currentTrack: track,
);
} else {
// TODO: Remove the ability to stop the playlist
// playlistNotifier.stop();
}
} }
await togglePlaylistLike.mutate(isLikedQuery.data!);
}, },
child: const TrackView(),
); );
} }
} }

View File

@ -114,8 +114,9 @@ class SearchPage extends HookConsumerWidget {
), ),
color: theme.scaffoldBackgroundColor, color: theme.scaffoldBackgroundColor,
child: TextField( child: TextField(
autofocus: autofocus: queries
queries.none((s) => s.hasPageData && !s.hasPageError), .none((s) => s.hasPageData && !s.hasPageError) &&
!kIsMobile,
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(SpotubeIcons.search), prefixIcon: const Icon(SpotubeIcons.search),
hintText: "${context.l10n.search}...", hintText: "${context.l10n.search}...",

View File

@ -32,7 +32,7 @@ class SearchArtistsSection extends HookConsumerWidget {
hasNextPage: query.hasNextPage, hasNextPage: query.hasNextPage,
items: artists, items: artists,
onFetchMore: query.fetchNext, onFetchMore: query.fetchNext,
title: Text(context.l10n.albums), title: Text(context.l10n.artists),
); );
} }
} }

View File

@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';

View File

@ -8,10 +8,10 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart';
import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/piped_instances_provider.dart'; import 'package:spotube/provider/piped_instances_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/sourced_track/enums.dart';
class SettingsPlaybackSection extends HookConsumerWidget { class SettingsPlaybackSection extends HookConsumerWidget {
const SettingsPlaybackSection({Key? key}) : super(key: key); const SettingsPlaybackSection({Key? key}) : super(key: key);
@ -25,17 +25,21 @@ class SettingsPlaybackSection extends HookConsumerWidget {
return SectionCardWithHeading( return SectionCardWithHeading(
heading: context.l10n.playback, heading: context.l10n.playback,
children: [ children: [
AdaptiveSelectTile<AudioQuality>( AdaptiveSelectTile<SourceQualities>(
secondary: const Icon(SpotubeIcons.audioQuality), secondary: const Icon(SpotubeIcons.audioQuality),
title: Text(context.l10n.audio_quality), title: Text(context.l10n.audio_quality),
value: preferences.audioQuality, value: preferences.audioQuality,
options: [ options: [
DropdownMenuItem( DropdownMenuItem(
value: AudioQuality.high, value: SourceQualities.high,
child: Text(context.l10n.high), child: Text(context.l10n.high),
), ),
DropdownMenuItem( DropdownMenuItem(
value: AudioQuality.low, value: SourceQualities.medium,
child: Text(context.l10n.medium),
),
DropdownMenuItem(
value: SourceQualities.low,
child: Text(context.l10n.low), child: Text(context.l10n.low),
), ),
], ],
@ -45,11 +49,11 @@ class SettingsPlaybackSection extends HookConsumerWidget {
} }
}, },
), ),
AdaptiveSelectTile<YoutubeApiType>( AdaptiveSelectTile<AudioSource>(
secondary: const Icon(SpotubeIcons.api), secondary: const Icon(SpotubeIcons.api),
title: Text(context.l10n.youtube_api_type), title: Text(context.l10n.audio_source),
value: preferences.youtubeApiType, value: preferences.audioSource,
options: YoutubeApiType.values options: AudioSource.values
.map((e) => DropdownMenuItem( .map((e) => DropdownMenuItem(
value: e, value: e,
child: Text(e.label), child: Text(e.label),
@ -57,12 +61,12 @@ class SettingsPlaybackSection extends HookConsumerWidget {
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
if (value == null) return; if (value == null) return;
preferencesNotifier.setYoutubeApiType(value); preferencesNotifier.setAudioSource(value);
}, },
), ),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: preferences.youtubeApiType == YoutubeApiType.youtube child: preferences.audioSource != AudioSource.piped
? const SizedBox.shrink() ? const SizedBox.shrink()
: Consumer(builder: (context, ref, child) { : Consumer(builder: (context, ref, child) {
final instanceList = ref.watch(pipedInstancesFutureProvider); final instanceList = ref.watch(pipedInstancesFutureProvider);
@ -129,7 +133,7 @@ class SettingsPlaybackSection extends HookConsumerWidget {
), ),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: preferences.youtubeApiType == YoutubeApiType.youtube child: preferences.audioSource != AudioSource.piped
? const SizedBox.shrink() ? const SizedBox.shrink()
: AdaptiveSelectTile<SearchMode>( : AdaptiveSelectTile<SearchMode>(
secondary: const Icon(SpotubeIcons.search), secondary: const Icon(SpotubeIcons.search),
@ -149,17 +153,18 @@ class SettingsPlaybackSection extends HookConsumerWidget {
), ),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: preferences.searchMode == SearchMode.youtubeMusic && child: preferences.searchMode == SearchMode.youtube &&
preferences.youtubeApiType == YoutubeApiType.piped (preferences.audioSource == AudioSource.piped ||
? const SizedBox.shrink() preferences.audioSource == AudioSource.youtube)
: SwitchListTile( ? SwitchListTile(
secondary: const Icon(SpotubeIcons.skip), secondary: const Icon(SpotubeIcons.skip),
title: Text(context.l10n.skip_non_music), title: Text(context.l10n.skip_non_music),
value: preferences.skipNonMusic, value: preferences.skipNonMusic,
onChanged: (state) { onChanged: (state) {
preferencesNotifier.setSkipNonMusic(state); preferencesNotifier.setSkipNonMusic(state);
}, },
), )
: const SizedBox.shrink(),
), ),
ListTile( ListTile(
leading: const Icon(SpotubeIcons.playlistRemove), leading: const Icon(SpotubeIcons.playlistRemove),
@ -176,44 +181,46 @@ class SettingsPlaybackSection extends HookConsumerWidget {
value: preferences.normalizeAudio, value: preferences.normalizeAudio,
onChanged: preferencesNotifier.setNormalizeAudio, onChanged: preferencesNotifier.setNormalizeAudio,
), ),
AdaptiveSelectTile<MusicCodec>( if (preferences.audioSource != AudioSource.jiosaavn)
secondary: const Icon(SpotubeIcons.stream), AdaptiveSelectTile<SourceCodecs>(
title: Text(context.l10n.streaming_music_codec), secondary: const Icon(SpotubeIcons.stream),
value: preferences.streamMusicCodec, title: Text(context.l10n.streaming_music_codec),
showValueWhenUnfolded: false, value: preferences.streamMusicCodec,
options: MusicCodec.values showValueWhenUnfolded: false,
.map((e) => DropdownMenuItem( options: SourceCodecs.values
value: e, .map((e) => DropdownMenuItem(
child: Text( value: e,
e.label, child: Text(
style: theme.textTheme.labelMedium, e.label,
), style: theme.textTheme.labelMedium,
)) ),
.toList(), ))
onChanged: (value) { .toList(),
if (value == null) return; onChanged: (value) {
preferencesNotifier.setStreamMusicCodec(value); if (value == null) return;
}, preferencesNotifier.setStreamMusicCodec(value);
), },
AdaptiveSelectTile<MusicCodec>( ),
secondary: const Icon(SpotubeIcons.file), if (preferences.audioSource != AudioSource.jiosaavn)
title: Text(context.l10n.download_music_codec), AdaptiveSelectTile<SourceCodecs>(
value: preferences.downloadMusicCodec, secondary: const Icon(SpotubeIcons.file),
showValueWhenUnfolded: false, title: Text(context.l10n.download_music_codec),
options: MusicCodec.values value: preferences.downloadMusicCodec,
.map((e) => DropdownMenuItem( showValueWhenUnfolded: false,
value: e, options: SourceCodecs.values
child: Text( .map((e) => DropdownMenuItem(
e.label, value: e,
style: theme.textTheme.labelMedium, child: Text(
), e.label,
)) style: theme.textTheme.labelMedium,
.toList(), ),
onChanged: (value) { ))
if (value == null) return; .toList(),
preferencesNotifier.setDownloadMusicCodec(value); onChanged: (value) {
}, if (value == null) return;
), preferencesNotifier.setDownloadMusicCodec(value);
},
),
], ],
); );
} }

View File

@ -30,12 +30,13 @@ class SettingsPage extends HookConsumerWidget {
title: Text(context.l10n.settings), title: Text(context.l10n.settings),
centerTitle: true, centerTitle: true,
), ),
body: Row( body: Scrollbar(
mainAxisAlignment: MainAxisAlignment.center, controller: controller,
children: [ child: Center(
Flexible( child: ConstrainedBox(
child: Container( constraints: const BoxConstraints(maxWidth: 1366),
constraints: const BoxConstraints(maxWidth: 1366), child: ScrollConfiguration(
behavior: const ScrollBehavior().copyWith(scrollbars: false),
child: ListView( child: ListView(
controller: controller, controller: controller,
children: [ children: [
@ -59,7 +60,7 @@ class SettingsPage extends HookConsumerWidget {
), ),
), ),
), ),
], ),
), ),
), ),
); );

View File

@ -9,25 +9,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:metadata_god/metadata_god.dart'; import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/download_manager/download_manager.dart';
import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadManagerProvider extends ChangeNotifier { class DownloadManagerProvider extends ChangeNotifier {
DownloadManagerProvider({required this.ref}) DownloadManagerProvider({required this.ref})
: $history = <SpotubeTrack>{}, : $history = <SourcedTrack>{},
$backHistory = <Track>{}, $backHistory = <Track>{},
dl = DownloadManager() { dl = DownloadManager() {
dl.statusStream.listen((event) async { dl.statusStream.listen((event) async {
final (:request, :status) = event; final (:request, :status) = event;
final track = $history.firstWhereOrNull( final track = $history.firstWhereOrNull(
(element) => element.ytUri == request.url, (element) => element.getUrlOfCodec(downloadCodec) == request.url,
); );
if (track == null) return; if (track == null) return;
@ -45,7 +43,7 @@ class DownloadManagerProvider extends ChangeNotifier {
//? WebA audiotagging is not supported yet //? WebA audiotagging is not supported yet
//? Although in future by converting weba to opus & then tagging it //? Although in future by converting weba to opus & then tagging it
//? is possible using vorbis comments //? is possible using vorbis comments
downloadCodec == MusicCodec.weba) return; downloadCodec == SourceCodecs.weba) return;
final file = File(request.path); final file = File(request.path);
@ -91,10 +89,9 @@ class DownloadManagerProvider extends ChangeNotifier {
final Ref<DownloadManagerProvider> ref; final Ref<DownloadManagerProvider> ref;
YoutubeEndpoints get yt => ref.read(youtubeProvider);
String get downloadDirectory => String get downloadDirectory =>
ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); ref.read(userPreferencesProvider.select((s) => s.downloadLocation));
MusicCodec get downloadCodec => SourceCodecs get downloadCodec =>
ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec)); ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec));
int get $downloadCount => dl int get $downloadCount => dl
@ -107,7 +104,7 @@ class DownloadManagerProvider extends ChangeNotifier {
) )
.length; .length;
final Set<SpotubeTrack> $history; final Set<SourcedTrack> $history;
// these are the tracks which metadata hasn't been fetched yet // these are the tracks which metadata hasn't been fetched yet
final Set<Track> $backHistory; final Set<Track> $backHistory;
final DownloadManager dl; final DownloadManager dl;
@ -144,9 +141,9 @@ class DownloadManagerProvider extends ChangeNotifier {
bool isActive(Track track) { bool isActive(Track track) {
if ($backHistory.contains(track)) return true; if ($backHistory.contains(track)) return true;
final spotubeTrack = mapToSpotubeTrack(track); final sourcedTrack = mapToSourcedTrack(track);
if (spotubeTrack == null) return false; if (sourcedTrack == null) return false;
return dl return dl
.getAllDownloads() .getAllDownloads()
@ -157,7 +154,7 @@ class DownloadManagerProvider extends ChangeNotifier {
download.status.value == DownloadStatus.queued, download.status.value == DownloadStatus.queued,
) )
.map((e) => e.request.url) .map((e) => e.request.url)
.contains(spotubeTrack.ytUri); .contains(sourcedTrack.getUrlOfCodec(downloadCodec));
} }
/// For singular downloads /// For singular downloads
@ -173,21 +170,27 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.rename("$savePath.old"); await oldFile.rename("$savePath.old");
} }
if (track is SpotubeTrack && track.codec == downloadCodec) { if (track is SourcedTrack && track.codec == downloadCodec) {
final downloadTask = await dl.addDownload(track.ytUri, savePath); final downloadTask =
await dl.addDownload(track.getUrlOfCodec(downloadCodec), savePath);
if (downloadTask != null) { if (downloadTask != null) {
$history.add(track); $history.add(track);
} }
} else { } else {
$backHistory.add(track); $backHistory.add(track);
final spotubeTrack = final sourcedTrack = await SourcedTrack.fetchFromTrack(
await SpotubeTrack.fetchFromTrack(track, yt, downloadCodec).then((d) { ref: ref,
track: track,
).then((d) {
$backHistory.remove(track); $backHistory.remove(track);
return d; return d;
}); });
final downloadTask = await dl.addDownload(spotubeTrack.ytUri, savePath); final downloadTask = await dl.addDownload(
sourcedTrack.getUrlOfCodec(downloadCodec),
savePath,
);
if (downloadTask != null) { if (downloadTask != null) {
$history.add(spotubeTrack); $history.add(sourcedTrack);
} }
} }
@ -196,7 +199,7 @@ class DownloadManagerProvider extends ChangeNotifier {
Future<void> batchAddToQueue(List<Track> tracks) async { Future<void> batchAddToQueue(List<Track> tracks) async {
$backHistory.addAll( $backHistory.addAll(
tracks.where((element) => element is! SpotubeTrack), tracks.where((element) => element is! SourcedTrack),
); );
notifyListeners(); notifyListeners();
for (final track in tracks) { for (final track in tracks) {
@ -216,25 +219,25 @@ class DownloadManagerProvider extends ChangeNotifier {
} }
} }
Future<void> removeFromQueue(SpotubeTrack track) async { Future<void> removeFromQueue(SourcedTrack track) async {
await dl.removeDownload(track.ytUri); await dl.removeDownload(track.getUrlOfCodec(downloadCodec));
$history.remove(track); $history.remove(track);
} }
Future<void> pause(SpotubeTrack track) { Future<void> pause(SourcedTrack track) {
return dl.pauseDownload(track.ytUri); return dl.pauseDownload(track.getUrlOfCodec(downloadCodec));
} }
Future<void> resume(SpotubeTrack track) { Future<void> resume(SourcedTrack track) {
return dl.resumeDownload(track.ytUri); return dl.resumeDownload(track.getUrlOfCodec(downloadCodec));
} }
Future<void> retry(SpotubeTrack track) { Future<void> retry(SourcedTrack track) {
return addToQueue(track); return addToQueue(track);
} }
void cancel(SpotubeTrack track) { void cancel(SourcedTrack track) {
dl.cancelDownload(track.ytUri); dl.cancelDownload(track.getUrlOfCodec(downloadCodec));
} }
void cancelAll() { void cancelAll() {
@ -244,20 +247,20 @@ class DownloadManagerProvider extends ChangeNotifier {
} }
} }
SpotubeTrack? mapToSpotubeTrack(Track track) { SourcedTrack? mapToSourcedTrack(Track track) {
if (track is SpotubeTrack) { if (track is SourcedTrack) {
return track; return track;
} else { } else {
return $history.firstWhereOrNull((element) => element.id == track.id); return $history.firstWhereOrNull((element) => element.id == track.id);
} }
} }
ValueNotifier<DownloadStatus>? getStatusNotifier(SpotubeTrack track) { ValueNotifier<DownloadStatus>? getStatusNotifier(SourcedTrack track) {
return dl.getDownload(track.ytUri)?.status; return dl.getDownload(track.getUrlOfCodec(downloadCodec))?.status;
} }
ValueNotifier<double>? getProgressNotifier(SpotubeTrack track) { ValueNotifier<double>? getProgressNotifier(SourcedTrack track) {
return dl.getDownload(track.ytUri)?.progress; return dl.getDownload(track.getUrlOfCodec(downloadCodec))?.progress;
} }
} }

View File

@ -1,10 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.dart'; import 'package:piped_client/piped_client.dart';
import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart';
final pipedInstancesFutureProvider = FutureProvider<List<PipedInstance>>( final pipedInstancesFutureProvider = FutureProvider<List<PipedInstance>>(
(ref) async { (ref) async {
final youtube = ref.watch(youtubeProvider); final pipedClient = ref.watch(pipedProvider);
return await youtube.piped?.instanceList() ?? [];
return await pipedClient.instanceList();
}, },
); );

View File

@ -3,36 +3,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/supabase.dart';
import 'package:spotube/services/youtube/youtube.dart';
final logger = getLogger("NextFetcherMixin"); final logger = getLogger("NextFetcherMixin");
mixin NextFetcher on StateNotifier<ProxyPlaylist> { mixin NextFetcher on StateNotifier<ProxyPlaylist> {
Future<List<SpotubeTrack>> fetchTracks( Future<List<SourcedTrack>> fetchTracks(
UserPreferences preferences, Ref ref, {
YoutubeEndpoints youtube, {
int count = 3, int count = 3,
int offset = 0, int offset = 0,
}) async { }) async {
/// get [count] [state.tracks] that are not [SpotubeTrack] and [LocalTrack] /// get [count] [state.tracks] that are not [SourcedTrack] and [LocalTrack]
final bareTracks = state.tracks final bareTracks = state.tracks
.skip(offset) .skip(offset)
.where((element) => element is! SpotubeTrack && element is! LocalTrack) .where((element) => element is! SourcedTrack && element is! LocalTrack)
.take(count); .take(count);
/// fetch [bareTracks] one by one with 100ms delay /// fetch [bareTracks] one by one with 100ms delay
final fetchedTracks = await Future.wait( final fetchedTracks = await Future.wait(
bareTracks.mapIndexed((i, track) async { bareTracks.mapIndexed((i, track) async {
final future = SpotubeTrack.fetchFromTrack( final future = SourcedTrack.fetchFromTrack(
track, ref: ref,
youtube, track: track,
preferences.streamMusicCodec,
); );
if (i == 0) { if (i == 0) {
return await future; return await future;
@ -47,9 +41,9 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
return fetchedTracks; return fetchedTracks;
} }
/// Merges List of [SpotubeTrack]s with [Track]s and outputs a mixed List /// Merges List of [SourcedTrack]s with [Track]s and outputs a mixed List
Set<Track> mergeTracks( Set<Track> mergeTracks(
Iterable<SpotubeTrack> fetchTracks, Iterable<SourcedTrack> fetchTracks,
Iterable<Track> tracks, Iterable<Track> tracks,
) { ) {
return tracks.map((track) { return tracks.map((track) {
@ -80,12 +74,12 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
/// Returns appropriate Media source for [Track] /// Returns appropriate Media source for [Track]
/// ///
/// * If [Track] is [SpotubeTrack] then return [SpotubeTrack.ytUri] /// * If [Track] is [SourcedTrack] then return [SourcedTrack.ytUri]
/// * If [Track] is [LocalTrack] then return [LocalTrack.path] /// * If [Track] is [LocalTrack] then return [LocalTrack.path]
/// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source /// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source
String makeAppropriateSource(Track track) { String makeAppropriateSource(Track track) {
if (track is SpotubeTrack) { if (track is SourcedTrack) {
return track.ytUri; return track.url;
} else if (track is LocalTrack) { } else if (track is LocalTrack) {
return track.path; return track.path;
} else { } else {
@ -103,7 +97,7 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
final track = state.tracks.firstWhereOrNull( final track = state.tracks.firstWhereOrNull(
(track) => (track) =>
trackToUnplayableSource(track) == source || trackToUnplayableSource(track) == source ||
(track is SpotubeTrack && track.ytUri == source) || (track is SourcedTrack && track.url == source) ||
(track is LocalTrack && track.path == source), (track is LocalTrack && track.path == source),
); );
return track; return track;
@ -111,23 +105,4 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
.whereNotNull() .whereNotNull()
.toList(); .toList();
} }
/// This method must be called after any playback operation as
/// it can increase the latency
Future<void> storeTrack(Track track, SpotubeTrack spotubeTrack) async {
try {
if (track is! SpotubeTrack) {
await supabase.insertTrack(
MatchedTrack(
youtubeId: spotubeTrack.ytTrack.id,
spotifyId: spotubeTrack.id!,
searchMode: spotubeTrack.ytTrack.searchMode,
),
);
}
} catch (e, stackTrace) {
logger.e(e.toString());
logger.t(stackTrace);
}
}
} }

View File

@ -1,8 +1,9 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
class ProxyPlaylist { class ProxyPlaylist {
final Set<Track> tracks; final Set<Track> tracks;
@ -11,11 +12,14 @@ class ProxyPlaylist {
ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]); ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]);
factory ProxyPlaylist.fromJson(Map<String, dynamic> json) { factory ProxyPlaylist.fromJson(
Map<String, dynamic> json,
Ref ref,
) {
return ProxyPlaylist( return ProxyPlaylist(
List.castFrom<dynamic, Map<String, dynamic>>( List.castFrom<dynamic, Map<String, dynamic>>(
json['tracks'] ?? <Map<String, dynamic>>[], json['tracks'] ?? <Map<String, dynamic>>[],
).map(_makeAppropriateTrack).toSet(), ).map((t) => _makeAppropriateTrack(t, ref)).toSet(),
json['active'] as int?, json['active'] as int?,
json['collections'] == null json['collections'] == null
? {} ? {}
@ -28,7 +32,7 @@ class ProxyPlaylist {
bool get isFetching => bool get isFetching =>
activeTrack != null && activeTrack != null &&
activeTrack is! SpotubeTrack && activeTrack is! SourcedTrack &&
activeTrack is! LocalTrack; activeTrack is! LocalTrack;
bool containsCollection(String collection) { bool containsCollection(String collection) {
@ -44,9 +48,9 @@ class ProxyPlaylist {
return tracks.every(containsTrack); return tracks.every(containsTrack);
} }
static Track _makeAppropriateTrack(Map<String, dynamic> track) { static Track _makeAppropriateTrack(Map<String, dynamic> track, Ref ref) {
if (track.containsKey("ytUri")) { if (track.containsKey("ytUri")) {
return SpotubeTrack.fromJson(track); return SourcedTrack.fromJson(track, ref: ref);
} else if (track.containsKey("path")) { } else if (track.containsKey("path")) {
return LocalTrack.fromJson(track); return LocalTrack.fromJson(track);
} else { } else {
@ -59,7 +63,7 @@ class ProxyPlaylist {
static Map<String, dynamic> _makeAppropriateTrackJson(Track track) { static Map<String, dynamic> _makeAppropriateTrackJson(Track track) {
return switch (track.runtimeType) { return switch (track.runtimeType) {
LocalTrack => track.toJson(), LocalTrack => track.toJson(),
SpotubeTrack => track.toJson(), SourcedTrack => track.toJson(),
_ => track.toJson(), _ => track.toJson(),
}; };
} }

View File

@ -12,9 +12,9 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart';
@ -22,17 +22,22 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_services/audio_services.dart'; import 'package:spotube/services/audio_services/audio_services.dart';
import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/services/discord/discord.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
/// Things implemented: /// Things implemented:
/// * [x] Sponsor-Block skip /// * [x] Sponsor-Block skip
/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track /// * [x] Prefetch next track as [SourcedTrack] on 80% of current track
/// * [x] Mixed Queue containing both [SpotubeTrack] and [LocalTrack] /// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack]
/// * [x] Modification of the Queue /// * [x] Modification of the Queue
/// * [x] Add track at the end /// * [x] Add track at the end
/// * [x] Add track at the beginning /// * [x] Add track at the beginning
@ -56,7 +61,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier);
UserPreferences get preferences => ref.read(userPreferencesProvider); UserPreferences get preferences => ref.read(userPreferencesProvider);
YoutubeEndpoints get youtube => ref.read(youtubeProvider);
ProxyPlaylist get playlist => state; ProxyPlaylist get playlist => state;
BlackListNotifier get blacklist => BlackListNotifier get blacklist =>
ref.read(BlackListNotifier.provider.notifier); ref.read(BlackListNotifier.provider.notifier);
@ -91,6 +95,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
notificationService.addTrack(newActiveTrack); notificationService.addTrack(newActiveTrack);
discord.updatePresence(newActiveTrack);
state = state.copyWith( state = state.copyWith(
active: state.tracks active: state.tracks
.toList() .toList()
@ -131,21 +136,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
try { try {
isPreSearching.value = true; isPreSearching.value = true;
final oldTrack =
mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull;
final track = await ensureSourcePlayable(audioPlayer.nextSource!); final track = await ensureSourcePlayable(audioPlayer.nextSource!);
if (track != null) { if (track != null) {
state = state.copyWith(tracks: mergeTracks([track], state.tracks)); state = state.copyWith(tracks: mergeTracks([track], state.tracks));
} }
if (oldTrack != null && track != null) {
await storeTrack(
oldTrack,
track,
);
}
} catch (e, stackTrace) { } catch (e, stackTrace) {
// Removing tracks that were not found to avoid queue interruption // Removing tracks that were not found to avoid queue interruption
// TODO: Add a flag to enable/disable skip not found tracks // TODO: Add a flag to enable/disable skip not found tracks
@ -168,11 +163,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
return; return;
} }
try { try {
final isYTMusicMode = final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack &&
preferences.youtubeApiType == YoutubeApiType.piped && (state.activeTrack is PipedSourcedTrack &&
preferences.searchMode == SearchMode.youtubeMusic; preferences.searchMode == SearchMode.youtubeMusic);
if (isYTMusicMode || !preferences.skipNonMusic) return; if (isNotYTMode || !preferences.skipNonMusic) return;
final isNotSameSegmentId = final isNotSameSegmentId =
currentSegments.value?.source != audioPlayer.currentSource; currentSegments.value?.source != audioPlayer.currentSource;
@ -184,7 +179,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
currentSegments.value = ( currentSegments.value = (
source: audioPlayer.currentSource!, source: audioPlayer.currentSource!,
segments: await getAndCacheSkipSegments( segments: await getAndCacheSkipSegments(
(state.activeTrack as SpotubeTrack).ytTrack.id, (state.activeTrack as SourcedTrack).sourceInfo.id,
), ),
); );
} catch (e) { } catch (e) {
@ -237,7 +232,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
}(); }();
} }
Future<SpotubeTrack?> ensureSourcePlayable(String source) async { Future<SourcedTrack?> ensureSourcePlayable(String source) async {
if (isPlayable(source)) return null; if (isPlayable(source)) return null;
final track = mapSourcesToTracks([source]).firstOrNull; final track = mapSourcesToTracks([source]).firstOrNull;
@ -247,17 +242,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
final nthFetchedTrack = switch (track.runtimeType) { final nthFetchedTrack = switch (track.runtimeType) {
SpotubeTrack => track as SpotubeTrack, SourcedTrack => track as SourcedTrack,
_ => await SpotubeTrack.fetchFromTrack( _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track),
track,
youtube,
preferences.streamMusicCodec,
),
}; };
await audioPlayer.replaceSource( await audioPlayer.replaceSource(
source, source,
nthFetchedTrack.ytUri, nthFetchedTrack.url,
); );
return nthFetchedTrack; return nthFetchedTrack;
@ -334,16 +325,15 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
collections: {}, collections: {},
); );
await notificationService.addTrack(indexTrack); await notificationService.addTrack(indexTrack);
discord.updatePresence(indexTrack);
} else { } else {
final addableTrack = await SpotubeTrack.fetchFromTrack( final addableTrack = await SourcedTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex) ?? tracks.first, ref: ref,
youtube, track: tracks.elementAtOrNull(initialIndex) ?? tracks.first,
preferences.streamMusicCodec,
).catchError((e, stackTrace) { ).catchError((e, stackTrace) {
return SpotubeTrack.fetchFromTrack( return SourcedTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, ref: ref,
youtube, track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first,
preferences.streamMusicCodec,
); );
}); });
@ -353,10 +343,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
collections: {}, collections: {},
); );
await notificationService.addTrack(addableTrack); await notificationService.addTrack(addableTrack);
await storeTrack( discord.updatePresence(addableTrack);
tracks.elementAt(initialIndex),
addableTrack,
);
} }
await audioPlayer.openPlaylist( await audioPlayer.openPlaylist(
@ -385,13 +372,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
if (oldTrack != null || track != null) { if (oldTrack != null || track != null) {
await notificationService.addTrack(track ?? oldTrack!); await notificationService.addTrack(track ?? oldTrack!);
} discord.updatePresence(track ?? oldTrack!);
if (oldTrack != null && track != null) {
await storeTrack(
oldTrack,
track,
);
} }
} }
@ -437,9 +418,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
Future<void> populateSibling() async { Future<void> populateSibling() async {
if (state.activeTrack is SpotubeTrack) { if (state.activeTrack is SourcedTrack) {
final activeTrackWithSiblingsForSure = final activeTrackWithSiblingsForSure =
await (state.activeTrack as SpotubeTrack).populatedCopy(youtube); await (state.activeTrack as SourcedTrack).copyWithSibling();
state = state.copyWith( state = state.copyWith(
tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks),
@ -449,11 +430,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
} }
} }
Future<void> swapSibling(YoutubeVideoInfo video) async { Future<void> swapSibling(SourceInfo sibling) async {
if (state.activeTrack is SpotubeTrack) { if (state.activeTrack is SourcedTrack) {
await populateSibling(); await populateSibling();
final newTrack = final newTrack =
await (state.activeTrack as SpotubeTrack).swappedCopy(video, youtube); await (state.activeTrack as SourcedTrack).swapWithSibling(sibling);
if (newTrack == null) return; if (newTrack == null) return;
state = state.copyWith( state = state.copyWith(
tracks: mergeTracks([newTrack], state.tracks), tracks: mergeTracks([newTrack], state.tracks),
@ -494,12 +475,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
if (oldTrack != null || track != null) { if (oldTrack != null || track != null) {
await notificationService.addTrack(track ?? oldTrack!); await notificationService.addTrack(track ?? oldTrack!);
} discord.updatePresence(track ?? oldTrack!);
if (oldTrack != null && track != null) {
await storeTrack(
oldTrack,
track,
);
} }
} }
@ -525,18 +501,14 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
await audioPlayer.skipToPrevious(); await audioPlayer.skipToPrevious();
if (oldTrack != null || track != null) { if (oldTrack != null || track != null) {
await notificationService.addTrack(track ?? oldTrack!); await notificationService.addTrack(track ?? oldTrack!);
} discord.updatePresence(track ?? oldTrack!);
if (oldTrack != null && track != null) {
await storeTrack(
oldTrack,
track,
);
} }
} }
Future<void> stop() async { Future<void> stop() async {
state = ProxyPlaylist({}); state = ProxyPlaylist({});
await audioPlayer.stop(); await audioPlayer.stop();
discord.clear();
} }
Future<void> updatePalette() async { Future<void> updatePalette() async {
@ -564,7 +536,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
Future<List<SkipSegment>> getAndCacheSkipSegments(String id) async { Future<List<SkipSegment>> getAndCacheSkipSegments(String id) async {
if (!preferences.skipNonMusic || if (!preferences.skipNonMusic ||
(preferences.youtubeApiType == YoutubeApiType.piped && (preferences.audioSource == AudioSource.piped &&
preferences.searchMode == SearchMode.youtubeMusic)) return []; preferences.searchMode == SearchMode.youtubeMusic)) return [];
try { try {
@ -652,7 +624,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
@override @override
FutureOr<ProxyPlaylist> fromJson(Map<String, dynamic> json) { FutureOr<ProxyPlaylist> fromJson(Map<String, dynamic> json) {
return ProxyPlaylist.fromJson(json); return ProxyPlaylist.fromJson(json, ref);
} }
@override @override

View File

@ -6,11 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -26,11 +26,11 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = UserPreferences.withDefaults(); state = UserPreferences.withDefaults();
} }
void setStreamMusicCodec(MusicCodec codec) { void setStreamMusicCodec(SourceCodecs codec) {
state = state.copyWith(streamMusicCodec: codec); state = state.copyWith(streamMusicCodec: codec);
} }
void setDownloadMusicCodec(MusicCodec codec) { void setDownloadMusicCodec(SourceCodecs codec) {
state = state.copyWith(downloadMusicCodec: codec); state = state.copyWith(downloadMusicCodec: codec);
} }
@ -60,7 +60,7 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = state.copyWith(checkUpdate: check); state = state.copyWith(checkUpdate: check);
} }
void setAudioQuality(AudioQuality quality) { void setAudioQuality(SourceQualities quality) {
state = state.copyWith(audioQuality: quality); state = state.copyWith(audioQuality: quality);
} }
@ -97,8 +97,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = state.copyWith(skipNonMusic: skip); state = state.copyWith(skipNonMusic: skip);
} }
void setYoutubeApiType(YoutubeApiType type) { void setAudioSource(AudioSource type) {
state = state.copyWith(youtubeApiType: type); state = state.copyWith(audioSource: type);
} }
void setSystemTitleBar(bool isSystemTitleBar) { void setSystemTitleBar(bool isSystemTitleBar) {

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/models/matched_track.dart'; import 'package:spotube/services/sourced_track/enums.dart';
part 'user_preferences_state.g.dart'; part 'user_preferences_state.g.dart';
@ -15,12 +15,6 @@ enum LayoutMode {
adaptive, adaptive,
} }
@JsonEnum()
enum AudioQuality {
high,
low,
}
@JsonEnum() @JsonEnum()
enum CloseBehavior { enum CloseBehavior {
minimizeToTray, minimizeToTray,
@ -28,9 +22,10 @@ enum CloseBehavior {
} }
@JsonEnum() @JsonEnum()
enum YoutubeApiType { enum AudioSource {
youtube, youtube,
piped; piped,
jiosaavn;
String get label => name[0].toUpperCase() + name.substring(1); String get label => name[0].toUpperCase() + name.substring(1);
} }
@ -44,13 +39,27 @@ enum MusicCodec {
const MusicCodec._(this.label); const MusicCodec._(this.label);
} }
@JsonEnum()
enum SearchMode {
youtube._("YouTube"),
youtubeMusic._("YouTube Music");
final String label;
const SearchMode._(this.label);
factory SearchMode.fromString(String key) {
return SearchMode.values.firstWhere((e) => e.name == key);
}
}
@JsonSerializable() @JsonSerializable()
final class UserPreferences { final class UserPreferences {
@JsonKey( @JsonKey(
defaultValue: AudioQuality.high, defaultValue: SourceQualities.high,
unknownEnumValue: AudioQuality.high, unknownEnumValue: SourceQualities.high,
) )
final AudioQuality audioQuality; final SourceQualities audioQuality;
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
final bool albumColorSync; final bool albumColorSync;
@ -172,22 +181,22 @@ final class UserPreferences {
final ThemeMode themeMode; final ThemeMode themeMode;
@JsonKey( @JsonKey(
defaultValue: YoutubeApiType.youtube, defaultValue: AudioSource.youtube,
unknownEnumValue: YoutubeApiType.youtube, unknownEnumValue: AudioSource.youtube,
) )
final YoutubeApiType youtubeApiType; final AudioSource audioSource;
@JsonKey( @JsonKey(
defaultValue: MusicCodec.weba, defaultValue: SourceCodecs.weba,
unknownEnumValue: MusicCodec.weba, unknownEnumValue: SourceCodecs.weba,
) )
final MusicCodec streamMusicCodec; final SourceCodecs streamMusicCodec;
@JsonKey( @JsonKey(
defaultValue: MusicCodec.m4a, defaultValue: SourceCodecs.m4a,
unknownEnumValue: MusicCodec.m4a, unknownEnumValue: SourceCodecs.m4a,
) )
final MusicCodec downloadMusicCodec; final SourceCodecs downloadMusicCodec;
UserPreferences({ UserPreferences({
required this.audioQuality, required this.audioQuality,
@ -207,7 +216,7 @@ final class UserPreferences {
required this.downloadLocation, required this.downloadLocation,
required this.pipedInstance, required this.pipedInstance,
required this.themeMode, required this.themeMode,
required this.youtubeApiType, required this.audioSource,
required this.streamMusicCodec, required this.streamMusicCodec,
required this.downloadMusicCodec, required this.downloadMusicCodec,
}); });
@ -229,7 +238,7 @@ final class UserPreferences {
SpotubeColor? accentColorScheme, SpotubeColor? accentColorScheme,
bool? albumColorSync, bool? albumColorSync,
bool? checkUpdate, bool? checkUpdate,
AudioQuality? audioQuality, SourceQualities? audioQuality,
String? downloadLocation, String? downloadLocation,
LayoutMode? layoutMode, LayoutMode? layoutMode,
CloseBehavior? closeBehavior, CloseBehavior? closeBehavior,
@ -238,13 +247,13 @@ final class UserPreferences {
String? pipedInstance, String? pipedInstance,
SearchMode? searchMode, SearchMode? searchMode,
bool? skipNonMusic, bool? skipNonMusic,
YoutubeApiType? youtubeApiType, AudioSource? audioSource,
Market? recommendationMarket, Market? recommendationMarket,
bool? saveTrackLyrics, bool? saveTrackLyrics,
bool? amoledDarkTheme, bool? amoledDarkTheme,
bool? normalizeAudio, bool? normalizeAudio,
MusicCodec? downloadMusicCodec, SourceCodecs? downloadMusicCodec,
MusicCodec? streamMusicCodec, SourceCodecs? streamMusicCodec,
bool? systemTitleBar, bool? systemTitleBar,
}) { }) {
return UserPreferences( return UserPreferences(
@ -261,7 +270,7 @@ final class UserPreferences {
pipedInstance: pipedInstance ?? this.pipedInstance, pipedInstance: pipedInstance ?? this.pipedInstance,
searchMode: searchMode ?? this.searchMode, searchMode: searchMode ?? this.searchMode,
skipNonMusic: skipNonMusic ?? this.skipNonMusic, skipNonMusic: skipNonMusic ?? this.skipNonMusic,
youtubeApiType: youtubeApiType ?? this.youtubeApiType, audioSource: audioSource ?? this.audioSource,
recommendationMarket: recommendationMarket ?? this.recommendationMarket, recommendationMarket: recommendationMarket ?? this.recommendationMarket,
amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme, amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme,
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,

View File

@ -9,9 +9,9 @@ part of 'user_preferences_state.dart';
UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) => UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) =>
UserPreferences( UserPreferences(
audioQuality: $enumDecodeNullable( audioQuality: $enumDecodeNullable(
_$AudioQualityEnumMap, json['audioQuality'], _$SourceQualitiesEnumMap, json['audioQuality'],
unknownValue: AudioQuality.high) ?? unknownValue: SourceQualities.high) ??
AudioQuality.high, SourceQualities.high,
albumColorSync: json['albumColorSync'] as bool? ?? true, albumColorSync: json['albumColorSync'] as bool? ?? true,
amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false,
checkUpdate: json['checkUpdate'] as bool? ?? true, checkUpdate: json['checkUpdate'] as bool? ?? true,
@ -51,23 +51,23 @@ UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) =>
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode'], themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode'],
unknownValue: ThemeMode.system) ?? unknownValue: ThemeMode.system) ??
ThemeMode.system, ThemeMode.system,
youtubeApiType: $enumDecodeNullable( audioSource: $enumDecodeNullable(
_$YoutubeApiTypeEnumMap, json['youtubeApiType'], _$AudioSourceEnumMap, json['audioSource'],
unknownValue: YoutubeApiType.youtube) ?? unknownValue: AudioSource.youtube) ??
YoutubeApiType.youtube, AudioSource.youtube,
streamMusicCodec: $enumDecodeNullable( streamMusicCodec: $enumDecodeNullable(
_$MusicCodecEnumMap, json['streamMusicCodec'], _$SourceCodecsEnumMap, json['streamMusicCodec'],
unknownValue: MusicCodec.weba) ?? unknownValue: SourceCodecs.weba) ??
MusicCodec.weba, SourceCodecs.weba,
downloadMusicCodec: $enumDecodeNullable( downloadMusicCodec: $enumDecodeNullable(
_$MusicCodecEnumMap, json['downloadMusicCodec'], _$SourceCodecsEnumMap, json['downloadMusicCodec'],
unknownValue: MusicCodec.m4a) ?? unknownValue: SourceCodecs.m4a) ??
MusicCodec.m4a, SourceCodecs.m4a,
); );
Map<String, dynamic> _$UserPreferencesToJson(UserPreferences instance) => Map<String, dynamic> _$UserPreferencesToJson(UserPreferences instance) =>
<String, dynamic>{ <String, dynamic>{
'audioQuality': _$AudioQualityEnumMap[instance.audioQuality]!, 'audioQuality': _$SourceQualitiesEnumMap[instance.audioQuality]!,
'albumColorSync': instance.albumColorSync, 'albumColorSync': instance.albumColorSync,
'amoledDarkTheme': instance.amoledDarkTheme, 'amoledDarkTheme': instance.amoledDarkTheme,
'checkUpdate': instance.checkUpdate, 'checkUpdate': instance.checkUpdate,
@ -85,14 +85,15 @@ Map<String, dynamic> _$UserPreferencesToJson(UserPreferences instance) =>
'downloadLocation': instance.downloadLocation, 'downloadLocation': instance.downloadLocation,
'pipedInstance': instance.pipedInstance, 'pipedInstance': instance.pipedInstance,
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'youtubeApiType': _$YoutubeApiTypeEnumMap[instance.youtubeApiType]!, 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!,
'streamMusicCodec': _$MusicCodecEnumMap[instance.streamMusicCodec]!, 'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!,
'downloadMusicCodec': _$MusicCodecEnumMap[instance.downloadMusicCodec]!, 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!,
}; };
const _$AudioQualityEnumMap = { const _$SourceQualitiesEnumMap = {
AudioQuality.high: 'high', SourceQualities.high: 'high',
AudioQuality.low: 'low', SourceQualities.medium: 'medium',
SourceQualities.low: 'low',
}; };
const _$CloseBehaviorEnumMap = { const _$CloseBehaviorEnumMap = {
@ -370,12 +371,13 @@ const _$ThemeModeEnumMap = {
ThemeMode.dark: 'dark', ThemeMode.dark: 'dark',
}; };
const _$YoutubeApiTypeEnumMap = { const _$AudioSourceEnumMap = {
YoutubeApiType.youtube: 'youtube', AudioSource.youtube: 'youtube',
YoutubeApiType.piped: 'piped', AudioSource.piped: 'piped',
AudioSource.jiosaavn: 'jiosaavn',
}; };
const _$MusicCodecEnumMap = { const _$SourceCodecsEnumMap = {
MusicCodec.m4a: 'm4a', SourceCodecs.m4a: 'm4a',
MusicCodec.weba: 'weba', SourceCodecs.weba: 'weba',
}; };

View File

@ -1,8 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/youtube/youtube.dart';
final youtubeProvider = Provider<YoutubeEndpoints>((ref) {
final preferences = ref.watch(userPreferencesProvider);
return YoutubeEndpoints(preferences);
});

View File

@ -5,9 +5,9 @@ import 'dart:async';
import 'package:media_kit/media_kit.dart' as mk; import 'package:media_kit/media_kit.dart' as mk;
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/loop_mode.dart';
import 'package:spotube/services/audio_player/playback_state.dart'; import 'package:spotube/services/audio_player/playback_state.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
part 'audio_players_streams_mixin.dart'; part 'audio_players_streams_mixin.dart';
part 'audio_player_impl.dart'; part 'audio_player_impl.dart';

View File

@ -121,11 +121,13 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
// } // }
} }
List<SpotubeTrack> resolveTracksForSource(List<SpotubeTrack> tracks) { // TODO: Make sure audio player soruces are also
return tracks.where((e) => sources.contains(e.ytUri)).toList(); // TODO: changed when preferences sources are changed
List<SourcedTrack> resolveTracksForSource(List<SourcedTrack> tracks) {
return tracks.where((e) => sources.contains(e.url)).toList();
} }
bool tracksExistsInPlaylist(List<SpotubeTrack> tracks) { bool tracksExistsInPlaylist(List<SourcedTrack> tracks) {
return resolveTracksForSource(tracks).length == tracks.length; return resolveTracksForSource(tracks).length == tracks.length;
} }

View File

@ -2,10 +2,10 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart';
import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class AudioServices { class AudioServices {
@ -47,8 +47,8 @@ class AudioServices {
album: track.album?.name ?? "", album: track.album?.name ?? "",
title: track.name!, title: track.name!,
artist: TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[]), artist: TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[]),
duration: track is SpotubeTrack duration: track is SourcedTrack
? track.ytTrack.duration ? track.sourceInfo.duration
: Duration(milliseconds: track.durationMs ?? 0), : Duration(milliseconds: track.durationMs ?? 0),
artUri: Uri.parse(TypeConversionUtils.image_X_UrlString( artUri: Uri.parse(TypeConversionUtils.image_X_UrlString(
track.album?.images ?? <Image>[], track.album?.images ?? <Image>[],

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