Merge branch 'KRTirtho:master' into master

This commit is contained in:
franchioping 2023-12-30 21:42:55 +00:00 committed by GitHub
commit 918a532830
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
96 changed files with 2699 additions and 1250 deletions

View File

@ -4,7 +4,7 @@ on:
inputs:
version:
description: Version to release (x.x.x)
default: 3.3.0
default: 3.4.0
required: true
channel:
type: choice
@ -26,18 +26,13 @@ on:
default: true
env:
FLUTTER_VERSION: '3.16.0'
FLUTTER_VERSION: '3.16.3'
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: KRTirtho/flutter_distributor
path: flutter_distributor
ref: fix-windows-build
- uses: subosito/flutter-action@v2.10.0
with:
cache: true
@ -79,10 +74,9 @@ jobs:
- name: Build Windows Executable
run: |
dart pub global activate melos
cd flutter_distributor && melos bs && cd ..
dart pub global activate flutter_distributor
make innoinstall
dart run ./flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=windows --targets=exe --skip-clean
flutter_distributor package --platform=windows --targets=exe --skip-clean
mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe
- name: Create Chocolatey Package and set hash
@ -169,7 +163,6 @@ jobs:
dart pub global activate flutter_distributor
alias dpkg-deb="dpkg-deb --Zxz"
flutter_distributor package --platform=linux --targets=deb
flutter_distributor package --platform=linux --targets=appimage
flutter_distributor package --platform=linux --targets=rpm
- name: Create tar.xz (stable)
@ -185,7 +178,6 @@ jobs:
mv build/spotube-linux-*-x86_64.tar.xz dist/
mv dist/**/spotube-*-linux.deb dist/Spotube-linux-x86_64.deb
mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-x86_64.rpm
mv dist/**/spotube-*-linux.AppImage dist/Spotube-linux-x86_64.AppImage
- uses: actions/upload-artifact@v3
@ -193,7 +185,6 @@ jobs:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
dist/Spotube-linux-x86_64.AppImage
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz

View File

@ -2,6 +2,32 @@
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.4.0](https://github.com/KRTirtho/spotube/compare/v3.3.0...v3.4.0) (2023-12-30)
### Features
* Add Go to Album option in track option [#917](https://github.com/KRTirtho/spotube/issues/917) ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050))
* **translations:** add Italian language translations ([#818](https://github.com/KRTirtho/spotube/issues/818)) ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), closes [#676](https://github.com/KRTirtho/spotube/issues/676) [#676](https://github.com/KRTirtho/spotube/issues/676)
* compact genre view in home page ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461))
* Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110))
* improve loading animations ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86))
* toggle for discord rpc ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6))
* **translations:** add Dutch Language ([#969](https://github.com/KRTirtho/spotube/issues/969)) ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc))
### Bug Fixes
* add safe area in home ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f))
* amoled mode and color scheme can't be changed ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84))
* doesn't minimize to tray when system title bar close button is used [#866](https://github.com/KRTirtho/spotube/issues/866) ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f))
* genre border issues ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6))
* Incorrect "Artist" label/heading on Search Results Page [#920](https://github.com/KRTirtho/spotube/issues/920) ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79))
* metadata not getting added for YouTube tracks [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38))
* Playlist refresh not working [#915](https://github.com/KRTirtho/spotube/issues/915) ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269))
* track view header title overflow and player view drag glitch ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e))
* wrong artist name sent while scrobbling [#958](https://github.com/KRTirtho/spotube/issues/958) ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9))
## [3.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27)

View File

@ -199,6 +199,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [SponsorBlock](https://sponsor.ajay.app) - SponsorBlock is an open-source crowdsourced browser extension and open API for skipping sponsor segments in YouTube videos.
1. [Inno Setup](https://jrsoftware.org/isinfo.php) - Inno Setup is a free installer for Windows programs by Jordan Russell and Martijn Laan
1. [F-Droid](https://f-droid.org) - F-Droid is an installable catalogue of FOSS (Free and Open Source Software) applications for the Android platform. The client makes it easy to browse, install, and keep track of updates on your device
1. [LastFM](https://last.fm) - Last.fm is a music streaming and discovery platform that helps users discover and share new music. It tracks users' music listening habits across many devices and platforms.
### 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.
@ -208,7 +209,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [auto_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds.
1. [buttons_tabbar](https://afonsoraposo.com) - A Flutter package that implements a TabBar where each label is a toggle button.
1. [cached_network_image](https://github.com/Baseflow/flutter_cached_network_image) - Flutter library to load and cache network images. Can also be used with placeholder and error widgets.
1. [catcher_2](https://github.com/ThexXTURBOXx/catcher_2) - Plugin for error catching which provides multiple handlers for dealing with errors when they are not caught by the developer.
1. [catcher_2](https://github.com/ThexXTURBOXx/catcher_2) - Plugin for error catching which provides multiple handlers for dealing with errors when they are not caught by the developer.
1. [collection](https://pub.dev/packages/collection) - Collections and utilities functions and classes related to collections.
1. [cupertino_icons](https://pub.dev/packages/cupertino_icons) - Default icons asset for Cupertino widgets based on Apple styled icons
1. [curved_navigation_bar](https://github.com/rafalbednarczuk/curved_navigation_bar) - Stunning Animating Curved Shape Navigation Bar. Adjustable color, background color, animation curve, animation duration.
@ -221,9 +222,9 @@ If you are concerned, you can [read the reason of choosing this license](https:/
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. [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_hooks](https://fl-query.krtirtho.dev) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter
1. [fl_query_devtools](https://fl-query.krtirtho.dev) - Devtools support for Fl-Query
1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft.
1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft.
1. [flutter_cache_manager](https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager) - Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite.
1. [flutter_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.
@ -234,7 +235,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android.
1. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files.
1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets
1. [fuzzywuzzy](https://github.com/sphericalkat/dart-fuzzywuzzy) - An implementation of the popular fuzzywuzzy package in Dart, to suit all your fuzzy string matching/searching needs!
1. [fuzzywuzzy](https://github.com/sphericalkat/dart-fuzzywuzzy) - An implementation of the popular fuzzywuzzy package in Dart, to suit all your fuzzy string matching/searching needs!
1. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling.
1. [go_router](https://pub.dev/packages/go_router) - A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more
1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256.
@ -251,10 +252,10 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms.
1. [metadata_god](https://github.com/KRTirtho/metadata_god) - Plugin for retrieving and writing audio tags/metadata from audio files
1. [mime](https://pub.dev/packages/mime) - Utilities for handling media (MIME) types, including determining a type from a file extension and file contents.
1. [package_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin for querying information about the application package, such as CFBundleVersion on iOS or versionCode on Android.
1. [package_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin for querying information about the application package, such as CFBundleVersion on iOS or versionCode on Android.
1. [palette_generator](https://pub.dev/packages/palette_generator) - Flutter package for generating palette colors from a source image.
1. [path](https://pub.dev/packages/path) - A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web.
1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories.
1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories.
1. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions.
1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video
1. [popover](https://github.com/minikin/popover) - A popover is a transient view that appears above other content onscreen when you tap a control or in an area.
@ -273,7 +274,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/
1. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback.
1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window.
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://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands.
1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification.
1. [file_picker](https://github.com/miguelpruivo/plugins_flutter_file_picker) - A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support.
1. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com
@ -282,6 +283,10 @@ If you are concerned, you can [read the reason of choosing this license](https:/
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. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort.
1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included).
1. [win32_registry](https://win32.pub) - A package that provides a friendly Dart API for accessing the Windows Registry.
1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app.
1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation.
1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied.
1. [flutter_distributor](https://distributor.leanflutter.org) - A complete tool for packaging and publishing your Flutter apps.

View File

@ -27,7 +27,7 @@
<activity
android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleInstance"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@ -48,6 +48,30 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="open.spotify.com"
/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with "spotify:// -->
<data android:scheme="spotify" />
</intent-filter>
</activity>
<!-- AudioService Config -->

View File

@ -35,6 +35,11 @@ void main(List<String> args) {
);
}
print(
"Prompt:\n"
"Translate following to their appropriate locale for flutter arb translations files."
" Put the respective new translations in a map of their corresponding locale.",
);
// ignore: avoid_print
print(
const JsonEncoder.withIndent(' ').convert(

167
lib/collections/fake.dart Normal file
View File

@ -0,0 +1,167 @@
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
abstract class FakeData {
static final Image image = Image()
..height = 1
..width = 1
..url = "url";
static final Followers followers = Followers()
..href = "text"
..total = 1;
static final Artist artist = Artist()
..id = "1"
..name = "Wow artist Good!"
..images = [image]
..popularity = 1
..type = "type"
..uri = "uri"
..externalUrls = externalUrls
..genres = ["genre"]
..href = "text"
..followers = followers;
static final externalIds = ExternalIds()
..isrc = "text"
..ean = "text"
..upc = "text";
static final externalUrls = ExternalUrls()..spotify = "text";
static final Album album = Album()
..id = "1"
..genres = ["genre"]
..label = "label"
..popularity = 1
..albumType = AlbumType.album
..artists = [artist]
..availableMarkets = [Market.BD]
..externalUrls = externalUrls
..href = "text"
..images = [image]
..name = "Another good album"
..releaseDate = "2021-01-01"
..releaseDatePrecision = DatePrecision.day
..tracks = [track]
..type = "type"
..uri = "uri"
..externalIds = externalIds
..copyrights = [
Copyright()
..type = CopyrightType.C
..text = "text",
];
static final ArtistSimple artistSimple = ArtistSimple()
..id = "1"
..name = "What an artist"
..type = "type"
..uri = "uri"
..externalUrls = externalUrls;
static final AlbumSimple albumSimple = AlbumSimple()
..id = "1"
..albumType = AlbumType.album
..artists = [artistSimple]
..availableMarkets = [Market.BD]
..externalUrls = externalUrls
..href = "text"
..images = [image]
..name = "A good album"
..releaseDate = "2021-01-01"
..releaseDatePrecision = DatePrecision.day
..type = "type"
..uri = "uri";
static final Track track = Track()
..id = "1"
..artists = [artist, artist, artist]
..album = albumSimple
..availableMarkets = [Market.BD]
..discNumber = 1
..durationMs = 50000
..explicit = false
..externalUrls = externalUrls
..href = "text"
..name = "A Track Name"
..popularity = 1
..previewUrl = "url"
..trackNumber = 1
..type = "type"
..uri = "uri"
..isPlayable = true
..explicit = false
..linkedFrom = trackLink;
static final TrackLink trackLink = TrackLink()
..id = "1"
..type = "type"
..uri = "uri"
..externalUrls = {"spotify": "text"}
..href = "text";
static final Paging<Track> paging = Paging()
..href = "text"
..itemsNative = [track.toJson()]
..limit = 1
..next = "text"
..offset = 1
..previous = "text"
..total = 1;
static final User user = User()
..id = "1"
..displayName = "Your Name"
..birthdate = "2021-01-01"
..country = Market.BD
..email = "test@email.com"
..followers = followers
..href = "text"
..images = [image]
..type = "type"
..uri = "uri";
static final TracksLink tracksLink = TracksLink()
..href = "text"
..total = 1;
static final Playlist playlist = Playlist()
..id = "1"
..collaborative = false
..description = "A very good playlist description"
..externalUrls = externalUrls
..followers = followers
..href = "text"
..images = [image]
..name = "A good playlist"
..owner = user
..public = true
..snapshotId = "text"
..tracks = paging
..tracksLink = tracksLink
..type = "type"
..uri = "uri";
static final PlaylistSimple playlistSimple = PlaylistSimple()
..id = "1"
..collaborative = false
..externalUrls = externalUrls
..href = "text"
..images = [image]
..name = "A good playlist"
..owner = user
..public = true
..snapshotId = "text"
..tracksLink = tracksLink
..type = "type"
..description = "A very good playlist description"
..uri = "uri";
static final Category category = Category()
..href = "text"
..icons = [image]
..id = "1"
..name = "category";
}

View File

@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
const gradients = [
LinearGradient(colors: [
Color.fromRGBO(123, 102, 255, 1),
Color.fromRGBO(95, 189, 255, 1),
Color.fromRGBO(150, 239, 255, 1),
Color.fromRGBO(197, 255, 248, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(245, 204, 160, 1),
Color.fromRGBO(228, 143, 69, 1),
Color.fromRGBO(153, 77, 28, 1),
Color.fromRGBO(107, 36, 12, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(243, 243, 243, 1),
Color.fromRGBO(197, 232, 152, 1),
Color.fromRGBO(41, 173, 178, 1),
Color.fromRGBO(7, 102, 173, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(240, 89, 65, 1),
Color.fromRGBO(190, 49, 68, 1),
Color.fromRGBO(135, 35, 65, 1),
Color.fromRGBO(34, 9, 44, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(119, 107, 93, 1),
Color.fromRGBO(176, 166, 149, 1),
Color.fromRGBO(235, 227, 213, 1),
Color.fromRGBO(243, 238, 234, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(208, 162, 247, 1),
Color.fromRGBO(220, 191, 255, 1),
Color.fromRGBO(229, 212, 255, 1),
Color.fromRGBO(241, 234, 255, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(221, 242, 253, 1),
Color.fromRGBO(155, 190, 200, 1),
Color.fromRGBO(66, 125, 157, 1),
Color.fromRGBO(22, 72, 99, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(119, 67, 219, 1),
Color.fromRGBO(195, 172, 208, 1),
Color.fromRGBO(247, 239, 229, 1),
Color.fromRGBO(255, 251, 245, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(194, 217, 255, 1),
Color.fromRGBO(142, 143, 250, 1),
Color.fromRGBO(119, 82, 254, 1),
Color.fromRGBO(25, 4, 130, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(104, 126, 255, 1),
Color.fromRGBO(128, 179, 255, 1),
Color.fromRGBO(152, 228, 255, 1),
Color.fromRGBO(182, 255, 250, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(176, 87, 141, 1),
Color.fromRGBO(217, 136, 185, 1),
Color.fromRGBO(250, 203, 234, 1),
Color.fromRGBO(255, 228, 214, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(190, 255, 247, 1),
Color.fromRGBO(166, 246, 255, 1),
Color.fromRGBO(158, 221, 255, 1),
Color.fromRGBO(100, 153, 233, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(245, 252, 205, 1),
Color.fromRGBO(120, 214, 198, 1),
Color.fromRGBO(65, 145, 151, 1),
Color.fromRGBO(18, 72, 107, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(229, 207, 247, 1),
Color.fromRGBO(157, 118, 193, 1),
Color.fromRGBO(113, 58, 190, 1),
Color.fromRGBO(91, 8, 136, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(249, 222, 201, 1),
Color.fromRGBO(247, 140, 162, 1),
Color.fromRGBO(216, 0, 50, 1),
Color.fromRGBO(61, 12, 17, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(242, 247, 161, 1),
Color.fromRGBO(53, 162, 159, 1),
Color.fromRGBO(8, 131, 149, 1),
Color.fromRGBO(7, 25, 82, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(243, 159, 90, 1),
Color.fromRGBO(174, 68, 90, 1),
Color.fromRGBO(102, 37, 73, 1),
Color.fromRGBO(69, 25, 82, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(255, 200, 200, 1),
Color.fromRGBO(255, 155, 130, 1),
Color.fromRGBO(255, 63, 164, 1),
Color.fromRGBO(87, 55, 93, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(238, 238, 238, 1),
Color.fromRGBO(100, 204, 197, 1),
Color.fromRGBO(23, 107, 135, 1),
Color.fromRGBO(5, 59, 80, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(198, 61, 47, 1),
Color.fromRGBO(226, 94, 62, 1),
Color.fromRGBO(255, 155, 80, 1),
Color.fromRGBO(255, 187, 92, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(236, 83, 176, 1),
Color.fromRGBO(157, 68, 192, 1),
Color.fromRGBO(77, 45, 183, 1),
Color.fromRGBO(14, 33, 160, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(242, 236, 190, 1),
Color.fromRGBO(226, 199, 153, 1),
Color.fromRGBO(192, 130, 97, 1),
Color.fromRGBO(154, 59, 59, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(255, 253, 140, 1),
Color.fromRGBO(151, 255, 244, 1),
Color.fromRGBO(112, 145, 245, 1),
Color.fromRGBO(121, 63, 223, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(67, 83, 52, 1),
Color.fromRGBO(158, 179, 132, 1),
Color.fromRGBO(206, 222, 189, 1),
Color.fromRGBO(250, 241, 228, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(250, 240, 230, 1),
Color.fromRGBO(185, 180, 199, 1),
Color.fromRGBO(92, 84, 112, 1),
Color.fromRGBO(53, 47, 68, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(255, 186, 134, 1),
Color.fromRGBO(246, 99, 92, 1),
Color.fromRGBO(194, 51, 115, 1),
Color.fromRGBO(121, 21, 91, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(213, 255, 208, 1),
Color.fromRGBO(64, 248, 255, 1),
Color.fromRGBO(39, 158, 255, 1),
Color.fromRGBO(12, 53, 106, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(131, 96, 150, 1),
Color.fromRGBO(237, 123, 123, 1),
Color.fromRGBO(240, 184, 110, 1),
Color.fromRGBO(235, 231, 108, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(63, 29, 56, 1),
Color.fromRGBO(77, 60, 119, 1),
Color.fromRGBO(162, 103, 138, 1),
Color.fromRGBO(225, 152, 152, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(254, 123, 229, 1),
Color.fromRGBO(151, 78, 195, 1),
Color.fromRGBO(80, 64, 153, 1),
Color.fromRGBO(49, 56, 102, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(248, 222, 34, 1),
Color.fromRGBO(249, 76, 16, 1),
Color.fromRGBO(199, 0, 57, 1),
Color.fromRGBO(144, 12, 63, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(101, 69, 31, 1),
Color.fromRGBO(118, 88, 39, 1),
Color.fromRGBO(200, 174, 125, 1),
Color.fromRGBO(234, 198, 150, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(255, 246, 224, 1),
Color.fromRGBO(216, 217, 218, 1),
Color.fromRGBO(97, 103, 122, 1),
Color.fromRGBO(39, 40, 41, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(145, 109, 179, 1),
Color.fromRGBO(228, 133, 134, 1),
Color.fromRGBO(252, 186, 173, 1),
Color.fromRGBO(253, 229, 236, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(124, 115, 192, 1),
Color.fromRGBO(148, 173, 215, 1),
Color.fromRGBO(172, 250, 223, 1),
Color.fromRGBO(232, 255, 206, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(174, 216, 204, 1),
Color.fromRGBO(205, 102, 136, 1),
Color.fromRGBO(122, 49, 111, 1),
Color.fromRGBO(70, 25, 89, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(237, 228, 255, 1),
Color.fromRGBO(215, 187, 245, 1),
Color.fromRGBO(160, 118, 249, 1),
Color.fromRGBO(101, 40, 247, 1)
]),
LinearGradient(colors: [
Color.fromRGBO(255, 236, 175, 1),
Color.fromRGBO(255, 176, 127, 1),
Color.fromRGBO(255, 82, 162, 1),
Color.fromRGBO(243, 21, 89, 1)
]),
];

View File

@ -0,0 +1,25 @@
import 'dart:io';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:win32_registry/win32_registry.dart';
Future<void> registerWindowsScheme(String scheme) async {
if (!DesktopTools.platform.isWindows) return;
String appPath = Platform.resolvedExecutable;
String protocolRegKey = 'Software\\Classes\\$scheme';
RegistryValue protocolRegValue = const RegistryValue(
'URL Protocol',
RegistryValueType.string,
'',
);
String protocolCmdRegKey = 'shell\\open\\command';
RegistryValue protocolCmdRegValue = RegistryValue(
'',
RegistryValueType.string,
'"$appPath" "%1"',
);
final regKey = Registry.currentUser.createKey(protocolRegKey);
regKey.createValue(protocolRegValue);
regKey.createKey(protocolCmdRegKey).createValue(protocolCmdRegValue);
}

View File

@ -164,10 +164,10 @@ abstract class LanguageLocals {
// name: "Maldivian;",
// nativeName: "ދިވެހި",
// ),
// "nl": const ISOLanguageName(
// name: "Dutch",
// nativeName: "Vlaams",
// ),
"nl": const ISOLanguageName(
name: "Dutch",
nativeName: "Nederlands",
),
"en": const ISOLanguageName(
name: "English",
nativeName: "English",

View File

@ -1,9 +1,11 @@
import 'package:catcher_2/catcher_2.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/foundation.dart' hide Category;
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
@ -38,6 +40,21 @@ final router = GoRouter(
GoRoute(
path: "/",
pageBuilder: (context, state) => const SpotubePage(child: HomePage()),
routes: [
GoRoute(
path: "genres",
pageBuilder: (context, state) =>
const SpotubePage(child: GenrePage()),
),
GoRoute(
path: "genre/:categoryId",
pageBuilder: (context, state) => SpotubePage(
child: GenrePlaylistsPage(
category: state.extra as Category,
),
),
),
],
),
GoRoute(
path: "/search",

View File

@ -108,4 +108,5 @@ abstract class SpotubeIcons {
static const noEye = FeatherIcons.eyeOff;
static const normalize = FeatherIcons.barChart2;
static const wikipedia = SimpleIcons.wikipedia;
static const discord = SimpleIcons.discord;
}

View File

@ -1,6 +1,7 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
@ -91,12 +92,14 @@ class ArtistCard extends HookConsumerWidget {
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(50)),
child: Text(
context.l10n.artist,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
child: Skeleton.ignore(
child: Text(
context.l10n.artist,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),

View File

@ -1,51 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/services/queries/queries.dart';
class CategoryCard extends HookConsumerWidget {
final Category category;
CategoryCard(
this.category, {
Key? key,
}) : super(key: key);
final logger = getLogger(CategoryCard);
@override
Widget build(BuildContext context, ref) {
final playlistQuery = useQueries.category.playlistsOf(
ref,
category.id!,
);
final playlists = useMemoized(
() => playlistQuery.pages.expand(
(page) {
return page.items?.whereNotNull() ??
const Iterable<PlaylistSimple>.empty();
},
).toList(),
[playlistQuery.pages],
);
if (playlistQuery.hasErrors &&
!playlistQuery.hasPageData &&
!playlistQuery.isLoadingNextPage) {
return const SizedBox.shrink();
}
return HorizontalPlaybuttonCardView<PlaylistSimple>(
title: Text(category.name!),
isLoadingNextPage: playlistQuery.isLoadingNextPage,
hasNextPage: playlistQuery.hasNextPage,
items: playlists,
onFetchMore: playlistQuery.fetchNext,
);
}
}

View File

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

View File

@ -0,0 +1,154 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/gradients.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/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/queries/queries.dart';
class HomeGenresSection extends HookConsumerWidget {
const HomeGenresSection({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final recommendationMarket = ref.watch(
userPreferencesProvider.select((s) => s.recommendationMarket),
);
final categoriesQuery =
useQueries.category.listAll(ref, recommendationMarket);
final categories = categoriesQuery.data
?.where((c) => (c.icons?.length ?? 0) > 0)
.take(mediaQuery.mdAndDown ? 6 : 10)
.toList() ??
<Category>[];
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.genres,
style: textTheme.headlineSmall,
),
Directionality(
textDirection: TextDirection.rtl,
child: TextButton.icon(
onPressed: () {
context.push('/genres');
},
icon: const Icon(SpotubeIcons.angleRight),
label: Text(
"Browse All",
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.secondary,
),
),
),
),
],
),
),
),
const SliverGap(8),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: Skeletonizer.sliver(
enabled: categoriesQuery.isLoading,
child: SliverGrid.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: mediaQuery.mdAndDown ? 200 : 250,
mainAxisExtent: 50,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: categoriesQuery.isLoading
? mediaQuery.mdAndDown
? 6
: 10
: categories.length,
itemBuilder: (context, index) {
final category =
categories.elementAtOrNull(index) ?? FakeData.category;
return HookBuilder(builder: (context) {
final (:gradient, :textColor) = useMemoized(
() {
final gradient =
gradients[Random().nextInt(gradients.length)];
final text = gradient.colors
.take(2)
.any((c) => c.computeLuminance() > 0.5)
? Colors.grey[900]
: Colors.white;
return (
gradient: LinearGradient(
colors: gradient.colors
.map((c) => c.withOpacity(0.8))
.toList(),
),
textColor: text
);
},
[],
);
return InkWell(
onTap: () {
context.push('/genre/${category.id}', extra: category);
},
borderRadius: BorderRadius.circular(8),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: UniversalImage.imageProvider(
category.icons!.first.url!,
),
fit: BoxFit.cover,
),
),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: colorScheme.surfaceVariant,
gradient: categoriesQuery.isLoading ? null : gradient,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
category.name!,
style: textTheme.titleMedium
?.copyWith(color: textColor),
),
),
),
),
);
});
},
),
),
),
],
);
}
}

View File

@ -0,0 +1,35 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/services/queries/queries.dart';
class HomeMadeForUserSection extends HookConsumerWidget {
const HomeMadeForUserSection({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final madeForUser = useQueries.views.get(ref, "made-for-x-hub");
return 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: () {},
);
},
);
}
}

View File

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

View File

@ -3,12 +3,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/context.dart';
@ -82,30 +84,39 @@ class UserAlbums extends HookConsumerWidget {
child: SingleChildScrollView(
padding: const EdgeInsets.all(8.0),
controller: controller,
child: Wrap(
runSpacing: 20,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (albums.isEmpty)
Container(
alignment: Alignment.topLeft,
padding: const EdgeInsets.all(16.0),
child: const ShimmerPlaybuttonCard(count: 4),
),
for (final album in albums)
AlbumCard(
TypeConversionUtils.simpleAlbum_X_Album(album),
),
if (albumsQuery.hasNextPage)
Waypoint(
controller: controller,
isGrid: true,
onTouchEdge: albumsQuery.fetchNext,
child: const ShimmerPlaybuttonCard(count: 1),
)
],
child: Skeletonizer(
enabled: albumsQuery.pages.isEmpty,
child: Center(
child: Wrap(
runSpacing: 20,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (albumsQuery.pages.isEmpty)
...List.generate(
10,
(index) => AlbumCard(FakeData.album),
)
else if (albums.isEmpty)
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
for (final album in albums)
AlbumCard(
TypeConversionUtils.simpleAlbum_X_Album(album),
),
if (albums.isNotEmpty && albumsQuery.hasNextPage)
Waypoint(
controller: controller,
isGrid: true,
onTouchEdge: albumsQuery.fetchNext,
child: AlbumCard(FakeData.album),
)
],
),
),
),
),
),

View File

@ -3,10 +3,13 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/artist/artist_card.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
@ -87,12 +90,29 @@ class UserArtists extends HookConsumerWidget {
width: double.infinity,
child: SafeArea(
child: Center(
child: Wrap(
spacing: 15,
runSpacing: 5,
children: filteredArtists
.mapIndexed((index, artist) => ArtistCard(artist))
.toList(),
child: Skeletonizer(
enabled: artistQuery.isLoading,
child: Wrap(
spacing: 15,
runSpacing: 5,
children: artistQuery.isLoading
? List.generate(
10, (index) => ArtistCard(FakeData.artist))
: filteredArtists.isEmpty
? [
const Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
NotFound(),
],
)
]
: filteredArtists
.mapIndexed((index, artist) =>
ArtistCard(artist))
.toList(),
),
),
),
),

View File

@ -11,12 +11,14 @@ import 'package:metadata_god/metadata_god.dart';
import 'package:mime/mime.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.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/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart';
@ -254,6 +256,15 @@ class UserLocalTracks extends HookConsumerWidget {
.toList();
}, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
return const Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
);
}
return Expanded(
child: RefreshIndicator(
onRefresh: () async {
@ -261,32 +272,48 @@ class UserLocalTracks extends HookConsumerWidget {
},
child: InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filteredTracks.length,
itemBuilder: (context, index) {
final track = filteredTracks[index];
return TrackTile(
index: index,
track: track,
userPlaylist: false,
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,
);
},
);
},
child: Skeletonizer(
enabled: trackSnapshot.isLoading,
child: ListView.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemCount:
trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) {
if (trackSnapshot.isLoading) {
return TrackTile(track: FakeData.track, index: index);
}
final track = filteredTracks[index];
return TrackTile(
index: index,
track: track,
userPlaylist: false,
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,
);
},
);
},
),
),
),
),
);
},
loading: () =>
const Expanded(child: ShimmerTrackTileGroup(noSliver: true)),
loading: () => Expanded(
child: Skeletonizer(
enabled: true,
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) =>
TrackTile(track: FakeData.track, index: index),
),
),
),
error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()),
)

View File

@ -1,16 +1,16 @@
import 'package:flutter/material.dart' hide Image;
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
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_playbutton_card.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/components/shared/waypoint.dart';
@ -123,7 +123,7 @@ class UserPlaylists extends HookConsumerWidget {
),
SliverLayoutBuilder(builder: (context, constrains) {
return SliverGrid.builder(
itemCount: playlists.length + 1,
itemCount: playlists.isEmpty ? 6 : playlists.length + 1,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: constrains.smAndDown ? 225 : 250,
@ -131,7 +131,7 @@ class UserPlaylists extends HookConsumerWidget {
mainAxisSpacing: 8,
),
itemBuilder: (context, index) {
if (index == playlists.length) {
if (playlists.isNotEmpty && index == playlists.length) {
if (!playlistsQuery.hasNextPage) {
return const SizedBox.shrink();
}
@ -140,11 +140,17 @@ class UserPlaylists extends HookConsumerWidget {
controller: controller,
isGrid: true,
onTouchEdge: playlistsQuery.fetchNext,
child: const ShimmerPlaybuttonCard(count: 1),
child: Skeletonizer(
enabled: true,
child: PlaylistCard(FakeData.playlistSimple),
),
);
}
return PlaylistCard(playlists[index]);
return PlaylistCard(
playlists.elementAtOrNull(index) ??
FakeData.playlistSimple,
);
},
);
})

View File

@ -28,9 +28,11 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerView extends HookConsumerWidget {
final PanelController panelController;
final ScrollController scrollController;
const PlayerView({
Key? key,
required this.panelController,
required this.scrollController,
}) : super(key: key);
@override
@ -72,10 +74,14 @@ class PlayerView extends HookConsumerWidget {
useMemoized(() => GlobalKey(), []);
useEffect(() {
WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false;
for (final renderView in WidgetsBinding.instance.renderViews) {
renderView.automaticSystemUiAdjustment = false;
}
return () {
WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = true;
for (final renderView in WidgetsBinding.instance.renderViews) {
renderView.automaticSystemUiAdjustment = true;
}
};
}, [panelController.isPanelOpen]);
@ -88,10 +94,10 @@ class PlayerView extends HookConsumerWidget {
final topPadding = MediaQueryData.fromView(View.of(context)).padding.top;
return WillPopScope(
onWillPop: () async {
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
panelController.close();
return false;
},
child: IconTheme(
data: theme.iconTheme.copyWith(color: bodyTextColor),
@ -119,40 +125,43 @@ class PlayerView extends HookConsumerWidget {
preferredSize: Size.fromHeight(
kToolbarHeight + topPadding,
),
child: Padding(
padding: EdgeInsets.only(top: topPadding),
child: PageWindowTitleBar(
backgroundColor: Colors.transparent,
foregroundColor: titleTextColor,
toolbarOpacity: 1,
leading: IconButton(
icon: const Icon(SpotubeIcons.angleDown, size: 18),
onPressed: panelController.close,
child: ForceDraggableWidget(
child: Padding(
padding: EdgeInsets.only(top: topPadding),
child: PageWindowTitleBar(
backgroundColor: Colors.transparent,
foregroundColor: titleTextColor,
toolbarOpacity: 1,
leading: IconButton(
icon: const Icon(SpotubeIcons.angleDown, size: 18),
onPressed: panelController.close,
),
actions: [
IconButton(
icon: const Icon(SpotubeIcons.info, size: 18),
tooltip: context.l10n.details,
style: IconButton.styleFrom(
foregroundColor: bodyTextColor),
onPressed: currentTrack == null
? null
: () {
showDialog(
context: context,
builder: (context) {
return TrackDetailsDialog(
track: currentTrack,
);
});
},
)
],
),
actions: [
IconButton(
icon: const Icon(SpotubeIcons.info, size: 18),
tooltip: context.l10n.details,
style:
IconButton.styleFrom(foregroundColor: bodyTextColor),
onPressed: currentTrack == null
? null
: () {
showDialog(
context: context,
builder: (context) {
return TrackDetailsDialog(
track: currentTrack,
);
});
},
)
],
),
),
),
extendBodyBehindAppBar: true,
body: SingleChildScrollView(
controller: scrollController,
child: Container(
alignment: Alignment.center,
width: double.infinity,
@ -163,27 +172,29 @@ class PlayerView extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Container(
margin: const EdgeInsets.all(8),
constraints: const BoxConstraints(
maxHeight: 300, maxWidth: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black26,
spreadRadius: 2,
blurRadius: 10,
offset: Offset(0, 0),
ForceDraggableWidget(
child: Container(
margin: const EdgeInsets.all(8),
constraints: const BoxConstraints(
maxHeight: 300, maxWidth: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black26,
spreadRadius: 2,
blurRadius: 10,
offset: Offset(0, 0),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: UniversalImage(
path: albumArt,
placeholder: Assets.albumPlaceholder.path,
fit: BoxFit.cover,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: UniversalImage(
path: albumArt,
placeholder: Assets.albumPlaceholder.path,
fit: BoxFit.cover,
),
),
),

View File

@ -43,6 +43,7 @@ class PlayerOverlay extends HookConsumerWidget {
final mediaQuery = MediaQuery.of(context);
final panelController = useMemoized(() => PanelController(), []);
final scrollController = useScrollController();
useEffect(() {
return () {
@ -174,6 +175,7 @@ class PlayerOverlay extends HookConsumerWidget {
),
),
),
scrollController: scrollController,
panelBuilder: (position) {
// this is the reason we're getting an update
final navigationHeight = ref.watch(navigationPanelHeight);
@ -188,8 +190,11 @@ class PlayerOverlay extends HookConsumerWidget {
decoration: navigationHeight == 0
? const BoxDecoration(borderRadius: BorderRadius.zero)
: const BoxDecoration(borderRadius: radius),
child: HorizontalScrollableWidget(
child: PlayerView(panelController: panelController),
child: IgnoreDraggableWidget(
child: PlayerView(
panelController: panelController,
scrollController: scrollController,
),
),
),
);

View File

@ -2,11 +2,12 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/artist/artist_card.dart';
import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -61,30 +62,41 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
PointerDeviceKind.mouse,
},
),
child: InfiniteList(
scrollController: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 8.0),
itemCount: items.length,
onFetchData: onFetchMore,
loadingBuilder: (context) => const ShimmerPlaybuttonCard(),
emptyBuilder: (context) =>
const ShimmerPlaybuttonCard(count: 5),
isLoading: isLoadingNextPage,
hasReachedMax: !hasNextPage,
itemBuilder: (context, index) {
final item = items[index];
child: items.isEmpty
? ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 5,
itemBuilder: (context, index) {
return AlbumCard(FakeData.albumSimple);
},
)
: InfiniteList(
scrollController: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 8.0),
itemCount: items.length,
onFetchData: onFetchMore,
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: AlbumCard(FakeData.albumSimple),
),
isLoading: isLoadingNextPage,
hasReachedMax: !hasNextPage,
itemBuilder: (context, index) {
final item = items[index];
return switch (item.runtimeType) {
PlaylistSimple => PlaylistCard(item as PlaylistSimple),
Album => AlbumCard(item as Album),
Artist => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: ArtistCard(item as Artist),
),
_ => const SizedBox.shrink(),
};
}),
return switch (item.runtimeType) {
PlaylistSimple =>
PlaylistCard(item as PlaylistSimple),
Album => AlbumCard(item as Album),
Artist => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0),
child: ArtistCard(item as Artist),
),
_ => const SizedBox.shrink(),
};
}),
),
),
],

View File

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

View File

@ -1,11 +1,13 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/hover_builder.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
@ -48,6 +50,7 @@ class PlaybuttonCard extends HookWidget {
Widget build(BuildContext context) {
final textsKey = useMemoized(() => GlobalKey(), []);
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final radius = BorderRadius.circular(15);
final double size = useBreakpointValue<double>(
@ -58,8 +61,8 @@ class PlaybuttonCard extends HookWidget {
);
final end = useBreakpointValue<double>(
xs: 10,
sm: 10,
xs: 7,
sm: 7,
others: 15,
);
@ -84,22 +87,28 @@ class PlaybuttonCard extends HookWidget {
splashFactory: theme.splashFactory,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Padding(
Container(
margin: const EdgeInsets.fromLTRB(8, 8, 8, 0),
padding: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
),
child: ClipRRect(
height: mediaQuery.smAndDown
? 120
: mediaQuery.mdAndDown
? 130
: 150,
decoration: BoxDecoration(
borderRadius: radius,
child: UniversalImage(
path: imageUrl,
placeholder: Assets.albumPlaceholder.path,
image: DecorationImage(
image: UniversalImage.imageProvider(imageUrl),
fit: BoxFit.cover,
),
),
),
@ -146,31 +155,35 @@ class PlaybuttonCard extends HookWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (!isPlaying)
IconButton(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.background,
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10),
Skeleton.keep(
child: IconButton(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.background,
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10),
),
icon: const Icon(SpotubeIcons.queueAdd),
onPressed: isLoading ? null : onAddToQueuePressed,
),
icon: const Icon(SpotubeIcons.queueAdd),
onPressed: isLoading ? null : onAddToQueuePressed,
),
const SizedBox(height: 5),
const Gap(5),
IconButton(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10),
),
icon: isLoading
? SizedBox.fromSize(
size: const Size.square(15),
child: const CircularProgressIndicator(
strokeWidth: 2),
)
: isPlaying
? const Icon(SpotubeIcons.pause)
: const Icon(SpotubeIcons.play),
icon: Skeleton.keep(
child: isLoading
? SizedBox.fromSize(
size: const Size.square(15),
child: const CircularProgressIndicator(
strokeWidth: 2),
)
: isPlaying
? const Icon(SpotubeIcons.pause)
: const Icon(SpotubeIcons.play),
),
onPressed: isLoading ? null : onPlaybuttonPressed,
),
],

View File

@ -1,57 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:skeleton_text/skeleton_text.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/extensions/theme.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
class ShimmerArtistProfile extends HookWidget {
const ShimmerArtistProfile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final shimmerTheme = ShimmerColorTheme(
shimmerBackgroundColor: isDark ? Colors.grey[700] : Colors.grey[200],
shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300],
);
final shimmerColor = shimmerTheme.shimmerColor ?? Colors.white;
final shimmerBackgroundColor =
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
final avatarWidth = useBreakpointValue(
xs: MediaQuery.of(context).size.width * 0.80,
sm: MediaQuery.of(context).size.width * 0.80,
md: MediaQuery.of(context).size.width * 0.50,
lg: MediaQuery.of(context).size.width * 0.30,
xl: MediaQuery.of(context).size.width * 0.30,
xxl: MediaQuery.of(context).size.width * 0.30,
) ??
0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(20),
child: SkeletonAnimation(
shimmerColor: shimmerColor,
borderRadius: BorderRadius.circular(avatarWidth),
shimmerDuration: 1000,
child: Container(
width: avatarWidth,
height: avatarWidth,
decoration: BoxDecoration(
color: shimmerBackgroundColor,
borderRadius: BorderRadius.circular(avatarWidth),
),
),
),
),
const SizedBox(width: 10),
const Flexible(child: ShimmerTrackTileGroup(noSliver: true)),
],
);
}
}

View File

@ -1,53 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
import 'package:spotube/extensions/theme.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
class ShimmerCategories extends HookWidget {
const ShimmerCategories({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final shimmerTheme = ShimmerColorTheme(
shimmerBackgroundColor: isDark ? Colors.grey[700] : Colors.grey[200],
);
final shimmerBackgroundColor =
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
final shimmerCount = useBreakpointValue(
xs: 2,
sm: 2,
md: 3,
lg: 3,
xl: 6,
xxl: 8,
);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.only(left: 15),
height: 10,
width: 100,
decoration: BoxDecoration(
color: shimmerBackgroundColor,
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 10),
Align(
alignment: Alignment.topLeft,
child: ShimmerPlaybuttonCard(count: shimmerCount),
),
],
),
);
}
}

View File

@ -1,69 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:skeleton_text/skeleton_text.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/theme.dart';
const widths = [20, 56, 89, 60, 25, 69];
import 'package:skeletonizer/skeletonizer.dart';
class ShimmerLyrics extends HookWidget {
const ShimmerLyrics({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final shimmerTheme = ShimmerColorTheme(
shimmerBackgroundColor: isDark ? Colors.grey[700] : Colors.grey[200],
shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300],
);
final shimmerColor = shimmerTheme.shimmerColor ?? Colors.white;
final shimmerBackgroundColor =
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
final mediaQuery = MediaQuery.of(context);
return ListView.builder(
itemCount: 20,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final widthsCp = [...widths];
if (mediaQuery.isMd) {
widthsCp.removeLast();
}
if (mediaQuery.smAndDown) {
widthsCp.removeLast();
widthsCp.removeLast();
}
widthsCp.shuffle();
return Container(
margin: const EdgeInsets.symmetric(vertical: 5),
child: Row(
return Skeletonizer(
enabled: true,
child: ListView.builder(
itemCount: 30,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemBuilder: (context, index) {
final texts = [
"Lorem ipsum",
"consectetur.",
"Sed",
"Sed non risus",
]..shuffle();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: widthsCp.map(
(width) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: SkeletonAnimation(
shimmerColor: shimmerColor,
shimmerDuration: 1000,
child: Container(
height: 10,
width: width.toDouble(),
decoration: BoxDecoration(
color: shimmerBackgroundColor,
borderRadius: BorderRadius.circular(10),
),
margin: const EdgeInsets.only(top: 10),
),
),
);
},
).toList(),
),
);
},
children: [
for (final text in texts) ...[
Text(text),
if (text != texts.last) const Gap(10),
],
],
);
},
),
);
}
}

View File

@ -1,119 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
class ShimmerPlaybuttonCardPainter extends CustomPainter {
final Color background;
final Color foreground;
ShimmerPlaybuttonCardPainter({
required this.background,
required this.foreground,
});
@override
void paint(Canvas canvas, Size size) {
const radius = Radius.circular(15);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
radius,
),
Paint()..color = background,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(8, 8, size.width - 16, size.height - 90),
radius,
),
Paint()..color = foreground,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(12, size.height - 67, size.width / 2, 10),
radius,
),
Paint()..color = foreground,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(12, size.height - 45, size.width - 24, 8),
radius,
),
Paint()..color = foreground,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(12, size.height - 30, size.width * .4, 8),
radius,
),
Paint()..color = foreground,
);
canvas.drawCircle(
Offset(size.width * .85, size.height * .50),
17,
Paint()..color = background,
);
canvas.drawCircle(
Offset(size.width * .85, size.height * .67),
17,
Paint()..color = background,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
class ShimmerPlaybuttonCard extends HookWidget {
final int count;
const ShimmerPlaybuttonCard({
Key? key,
this.count = 1,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Size size = useBreakpointValue<Size>(
xs: const Size(130, 200),
sm: const Size(130, 200),
md: const Size(150, 220),
others: const Size(170, 240),
);
final isDark = theme.brightness == Brightness.dark;
final bgColor = theme.colorScheme.surfaceVariant.withOpacity(.2);
final fgColor = Color.lerp(
theme.colorScheme.surfaceVariant,
isDark ? Colors.black : Colors.white,
.4,
);
return Wrap(
spacing: 20,
runSpacing: 20,
children: [
for (var i = 0; i < count; i++) ...[
CustomPaint(
size: size,
painter: ShimmerPlaybuttonCardPainter(
background: bgColor,
foreground: fgColor!,
),
),
]
],
);
}
}

View File

@ -1,123 +0,0 @@
import 'package:flutter/material.dart';
import 'package:spotube/extensions/theme.dart';
class ShimmerTrackTilePainter extends CustomPainter {
final Color background;
final Color foreground;
ShimmerTrackTilePainter({
required this.background,
required this.foreground,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = background
..style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
const Radius.circular(5),
),
paint,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.height, size.height),
const Radius.circular(5),
),
Paint()..color = foreground,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(70, 10, 100, 10),
const Radius.circular(5),
),
Paint()..color = foreground,
);
// draw Icons.play
const icon = Icons.play_arrow_outlined;
TextPainter textPainter = TextPainter(textDirection: TextDirection.rtl);
textPainter.text = TextSpan(
text: String.fromCharCode(icon.codePoint),
style: TextStyle(
fontSize: 40.0,
fontFamily: icon.fontFamily,
color: background,
),
);
textPainter.layout();
textPainter.paint(canvas, const Offset(10, 10));
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(70, 30, 170, 7),
const Radius.circular(5),
),
Paint()..color = foreground,
);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
class ShimmerTrackTile extends StatelessWidget {
const ShimmerTrackTile({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final shimmerTheme = ShimmerColorTheme(
shimmerBackgroundColor: isDark ? Colors.grey[700] : Colors.grey[200],
shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300],
);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
child: CustomPaint(
size: const Size(double.infinity, 60),
painter: ShimmerTrackTilePainter(
background: shimmerTheme.shimmerBackgroundColor ??
theme.scaffoldBackgroundColor,
foreground: shimmerTheme.shimmerColor ?? theme.cardColor,
),
),
);
}
}
class ShimmerTrackTileGroup extends StatelessWidget {
final bool noSliver;
final int count;
const ShimmerTrackTileGroup({
super.key,
this.noSliver = false,
this.count = 5,
});
@override
Widget build(BuildContext context) {
if (noSliver) {
return ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => const ShimmerTrackTile(),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => const ShimmerTrackTile(),
childCount: count,
),
);
}
}

View File

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

View File

@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/hover_builder.dart';
@ -158,26 +159,28 @@ class TrackTile extends HookConsumerWidget {
child: IconTheme(
data: theme.iconTheme
.copyWith(size: 26, color: Colors.white),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: (isPlaying && playlist.isFetching) ||
isLoading.value
? const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Colors.white,
),
)
: isPlaying
? Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
)
: !isHovering
? const SizedBox.shrink()
: const Icon(SpotubeIcons.play),
child: Skeleton.ignore(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: (isPlaying && playlist.isFetching) ||
isLoading.value
? const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Colors.white,
),
)
: isPlaying
? Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
)
: !isHovering
? const SizedBox.shrink()
: const Icon(SpotubeIcons.play),
),
),
),
),

View File

@ -4,9 +4,11 @@ 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:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.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/fallbacks/not_found.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';
@ -84,7 +86,22 @@ class TrackViewBodySection extends HookConsumerWidget {
onFetchData: props.pagination.onFetchMore,
isLoading: props.pagination.isLoading,
hasReachedMax: !props.pagination.hasNextPage,
loadingBuilder: (context) => const ShimmerTrackTile(),
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: TrackTile(
track: FakeData.track,
index: 0,
),
),
emptyBuilder: (context) => Skeletonizer(
enabled: true,
child: Column(
children: List.generate(
10,
(index) => TrackTile(track: FakeData.track, index: index),
),
),
),
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(

View File

@ -88,50 +88,68 @@ class TrackViewFlexHeader extends HookConsumerWidget {
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,
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: mediaQuery.mdAndDown
? mediaQuery.size.width
: 800,
),
child: 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,
const Gap(20),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: mediaQuery.mdAndDown
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
Text(
props.title,
style: headingStyle,
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Gap(10),
const TrackViewHeaderActions(),
const Gap(10),
TrackViewHeaderButtons(color: palette),
],
),
],
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

@ -3,7 +3,6 @@ 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';
@ -28,16 +27,17 @@ class TrackView extends HookConsumerWidget {
)
: null,
extendBodyBehindAppBar: true,
body: CustomScrollView(
slivers: [
const TrackViewFlexHeader(),
SliverAnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: props.tracks.isEmpty
? const ShimmerTrackTileGroup()
: const TrackViewBodySection(),
),
],
body: RefreshIndicator(
onRefresh: props.pagination.onRefresh,
child: const CustomScrollView(
slivers: [
TrackViewFlexHeader(),
SliverAnimatedSwitcher(
duration: Duration(milliseconds: 500),
child: TrackViewBodySection(),
),
],
),
),
);
}

View File

@ -6,6 +6,7 @@ class PaginationProps {
final bool hasNextPage;
final bool isLoading;
final VoidCallback onFetchMore;
final Future<void> Function() onRefresh;
final Future<List<Track>> Function() onFetchAll;
const PaginationProps({
@ -13,6 +14,7 @@ class PaginationProps {
required this.isLoading,
required this.onFetchMore,
required this.onFetchAll,
required this.onRefresh,
});
factory PaginationProps.fromQuery(
@ -24,6 +26,7 @@ class PaginationProps {
isLoading: query.isLoadingNextPage,
onFetchMore: query.fetchNext,
onFetchAll: onFetchAll,
onRefresh: query.refreshAll,
);
}
@ -33,7 +36,8 @@ class PaginationProps {
other.hasNextPage == hasNextPage &&
other.isLoading == isLoading &&
other.onFetchMore == onFetchMore &&
other.onFetchAll == onFetchAll;
other.onFetchAll == onFetchAll &&
other.onRefresh == onRefresh;
}
@override
@ -42,7 +46,8 @@ class PaginationProps {
hasNextPage.hashCode ^
isLoading.hashCode ^
onFetchMore.hashCode ^
onFetchAll.hashCode;
onFetchAll.hashCode ^
onRefresh.hashCode;
}
class InheritedTrackView extends InheritedWidget {

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,100 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:flutter_sharing_intent/flutter_sharing_intent.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
void useDeepLinking(WidgetRef ref) {
// single instance no worries
final appLinks = AppLinks();
final spotify = ref.watch(spotifyProvider);
final queryClient = useQueryClient();
useEffect(() {
void uriListener(List<SharedFile> files) async {
for (final file in files) {
if (file.type != SharedMediaType.URL) continue;
final url = Uri.parse(file.value!);
if (url.pathSegments.length != 2) continue;
switch (url.pathSegments.first) {
case "album":
router.push(
"/album/${url.pathSegments.last}",
extra: await queryClient.fetchQuery<Album, dynamic>(
"album/${url.pathSegments.last}",
() => spotify.albums.get(url.pathSegments.last),
),
);
break;
case "artist":
router.push("/artist/${url.pathSegments.last}");
break;
case "playlist":
router.push(
"/playlist/${url.pathSegments.last}",
extra: await queryClient.fetchQuery<Playlist, dynamic>(
"playlist/${url.pathSegments.last}",
() => spotify.playlists.get(url.pathSegments.last),
),
);
break;
default:
break;
}
}
}
StreamSubscription? mediaStream;
if (DesktopTools.platform.isMobile) {
FlutterSharingIntent.instance.getInitialSharing().then(uriListener);
mediaStream =
FlutterSharingIntent.instance.getMediaStream().listen(uriListener);
}
final subscription = appLinks.allStringLinkStream.listen((uri) async {
final startSegment = uri.split(":").take(2).join(":");
final endSegment = uri.split(":").last;
switch (startSegment) {
case "spotify:album":
await router.push(
"/album/$endSegment",
extra: await queryClient.fetchQuery<Album, dynamic>(
"album/$endSegment",
() => spotify.albums.get(endSegment),
),
);
break;
case "spotify:artist":
await router.push("/artist/$endSegment");
break;
case "spotify:playlist":
await router.push(
"/playlist/$endSegment",
extra: await queryClient.fetchQuery<Playlist, dynamic>(
"playlist/$endSegment",
() => spotify.playlists.get(endSegment),
),
);
break;
default:
break;
}
});
return () {
mediaStream?.cancel();
subscription.cancel();
};
}, [spotify, queryClient]);
}

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": "المطورون",
"not_logged_in": "لم تقم بتسجيل الدخول",
"search_mode": "وضع البحث",
"youtube_api_type": "نوع الـAPI",
"audio_source": "مصدر الصوت",
"ok": "حسسناً",
"failed_to_encrypt": "فشل في التشفير",
"encryption_failed_warning": "يستخدم Spotube التشفير لتخزين بياناتك بشكل آمن. لكنها فشلت في القيام بذلك. لذلك سيعود الأمر إلى التخزين غير الآمن\nإذا كنت تستخدم Linux، فيرجى التأكد من تثبيت أي خدمة سرية (gnome-keyring، kde-wallet، keepassxc، إلخ)",
@ -279,5 +279,10 @@
"password": "كلمة المرور",
"login": "تسجيل الدخول",
"login_with_your_lastfm": "تسجيل الدخول باستخدام حساب Last.fm الخاص بك",
"scrobble_to_lastfm": "تسجيل الاستماع على Last.fm"
"scrobble_to_lastfm": "تسجيل الاستماع على Last.fm",
"go_to_album": "الانتقال إلى الألبوم",
"discord_rich_presence": "وجود ديسكورد الغني",
"browse_all": "تصفح الكل",
"genres": "الأنواع الموسيقية",
"explore_genres": "استكشاف الأنواع"
}

View File

@ -249,7 +249,7 @@
"developers": "ডেভেলপার",
"not_logged_in": "আপনি লগইন করা নেই",
"search_mode": "অনুসন্ধান মোড",
"youtube_api_type": "API প্রকার",
"audio_source": "অডিও উৎস",
"ok": "ঠিক আছে",
"failed_to_encrypt": "এনক্রিপ্ট করা ব্যর্থ হয়েছে",
"encryption_failed_warning": "Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে",
@ -279,5 +279,10 @@
"password": "পাসওয়ার্ড",
"login": "লগইন",
"login_with_your_lastfm": "আপনার Last.fm অ্যাকাউন্ট দিয়ে লগইন করুন",
"scrobble_to_lastfm": "Last.fm এ স্ক্রবল করুন"
"scrobble_to_lastfm": "Last.fm এ স্ক্রবল করুন",
"go_to_album": "الانتقال إلى الألبوم",
"discord_rich_presence": "وجود ديسكورد الغني",
"browse_all": "تصفح الكل",
"genres": "الأنواع الموسيقية",
"explore_genres": "استكشاف الأنواع"
}

View File

@ -249,7 +249,7 @@
"developers": "Desenvolupadors",
"not_logged_in": "No ha iniciat sesió",
"search_mode": "Mode de cerca",
"youtube_api_type": "Tipus d'API de YouTube",
"audio_source": "Font d'àudio",
"ok": "OK",
"failed_to_encrypt": "Error al xifrar",
"encryption_failed_warning": "Spotube utilitza el xifrado per emmagatzemar les seves dades de forma segura. Però ha fallat. Per tant, tornarà a un emmagatzament no segur\nSi estè utilizant Linux, asseguri's de tenir instal·lats els serveis secrets com gnome-keyring, kde-wallet i keepassxc",
@ -279,5 +279,10 @@
"password": "Contrasenya",
"login": "Inicia la sessió",
"login_with_your_lastfm": "Inicia la sessió amb el teu compte de Last.fm",
"scrobble_to_lastfm": "Scrobble a Last.fm"
"scrobble_to_lastfm": "Scrobble a Last.fm",
"go_to_album": "Anar a l'àlbum",
"discord_rich_presence": "Presència rica de Discord",
"browse_all": "Navega per tot",
"genres": "Gèneres",
"explore_genres": "Explora els gèneres"
}

View File

@ -249,7 +249,7 @@
"developers": "Entwickler",
"not_logged_in": "Sie sind nicht angemeldet",
"search_mode": "Suchmodus",
"youtube_api_type": "API-Typ",
"audio_source": "Audioquelle",
"ok": "OK",
"failed_to_encrypt": "Verschlüsselung fehlgeschlagen",
"encryption_failed_warning": "Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben",
@ -279,5 +279,10 @@
"password": "Passwort",
"login": "Anmelden",
"login_with_your_lastfm": "Mit Ihrem Last.fm-Konto anmelden",
"scrobble_to_lastfm": "Auf Last.fm scrobbeln"
"scrobble_to_lastfm": "Auf Last.fm scrobbeln",
"go_to_album": "Zum Album gehen",
"discord_rich_presence": "Discord Rich Presence",
"browse_all": "Alles durchsuchen",
"genres": "Genres",
"explore_genres": "Genres erkunden"
}

View File

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

View File

@ -249,7 +249,7 @@
"developers": "Desarrolladores",
"not_logged_in": "No has iniciado sesión",
"search_mode": "Modo de búsqueda",
"youtube_api_type": "Tipo de API de YouTube",
"audio_source": "Fuente de audio",
"ok": "OK",
"failed_to_encrypt": "Error al cifrar",
"encryption_failed_warning": "Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc",
@ -279,5 +279,10 @@
"password": "Contraseña",
"login": "Iniciar sesión",
"login_with_your_lastfm": "Iniciar sesión con tu cuenta de Last.fm",
"scrobble_to_lastfm": "Scrobble a Last.fm"
"scrobble_to_lastfm": "Scrobble a Last.fm",
"go_to_album": "Ir al álbum",
"discord_rich_presence": "Presencia rica en Discord",
"browse_all": "Explorar todo",
"genres": "Géneros",
"explore_genres": "Explorar géneros"
}

View File

@ -251,7 +251,7 @@
"developers": "توسعه دهنده ها",
"not_logged_in": "شما وارد نشده اید ",
"search_mode": "حالت جستجو",
"youtube_api_type": "API نوع",
"audio_source": "منبع صدا",
"ok": "باشد",
"failed_to_encrypt": "رمز گذاری نشده",
"encryption_failed_warning": "Spotube از رمزگذاری برای ذخیره ایمن داده های شما استفاده می کند. اما موفق به انجام این کار نشد. بنابراین به فضای ذخیره‌سازی ناامن تبدیل می‌شود\nاگر از لینوکس استفاده می‌کنید، لطفاً مطمئن شوید که سرویس مخفی (gnome-keyring، kde-wallet، keepassxc و غیره) را نصب کرده‌اید.",
@ -279,5 +279,10 @@
"password": "رمز عبور",
"login": "ورود",
"login_with_your_lastfm": "ورود با حساب کاربری Last.fm خود",
"scrobble_to_lastfm": "Scrobble به Last.fm"
"scrobble_to_lastfm": "Scrobble به Last.fm",
"go_to_album": "رفتن به آلبوم",
"discord_rich_presence": "حضور غنی دیسکورد",
"browse_all": "مرور همه",
"genres": "ژانرها",
"explore_genres": "استکشاف ژانرها"
}

View File

@ -249,7 +249,7 @@
"developers": "Développeurs",
"not_logged_in": "Vous n'êtes pas connecté(e)",
"search_mode": "Mode de recherche",
"youtube_api_type": "Type d'API",
"audio_source": "Source audio",
"ok": "OK",
"failed_to_encrypt": "Échec de la cryptage",
"encryption_failed_warning": "Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc",
@ -279,5 +279,10 @@
"password": "Mot de passe",
"login": "Se connecter",
"login_with_your_lastfm": "Se connecter avec votre compte Last.fm",
"scrobble_to_lastfm": "Scrobble à Last.fm"
"scrobble_to_lastfm": "Scrobble à Last.fm",
"go_to_album": "Aller à l'album",
"discord_rich_presence": "Présence riche de Discord",
"browse_all": "Parcourir tout",
"genres": "Genres",
"explore_genres": "Explorer les genres"
}

View File

@ -249,7 +249,7 @@
"developers": "डेवलपर्स",
"not_logged_in": "आप लॉग इन नहीं हैं",
"search_mode": "खोज मोड",
"youtube_api_type": "API प्रकार",
"audio_source": "ऑडियो स्रोत",
"ok": "ठीक है",
"failed_to_encrypt": "एन्क्रिप्ट करने में विफल रहा",
"encryption_failed_warning": "Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है",
@ -279,5 +279,10 @@
"password": "पासवर्ड",
"login": "लॉग इन करें",
"login_with_your_lastfm": "अपने Last.fm अकाउंट से लॉगिन करें",
"scrobble_to_lastfm": "Last.fm पर स्क्रॉबल करें"
"scrobble_to_lastfm": "Last.fm पर स्क्रॉबल करें",
"go_to_album": "एल्बम पर जाएं",
"discord_rich_presence": "डिस्कॉर्ड रिच प्रेजेंस",
"browse_all": "सभी को ब्राउज़ करें",
"genres": "शैलियाँ",
"explore_genres": "शैलियों का अन्वेषण करें"
}

View File

@ -279,5 +279,11 @@
"password": "Password",
"login": "Accesso",
"login_with_your_lastfm": "Accedi con il tuo account Last.fm",
"scrobble_to_lastfm": "Invia a Last.fm"
}
"scrobble_to_lastfm": "Invia a Last.fm",
"audio_source": "Fonte audio",
"go_to_album": "Vai all'album",
"discord_rich_presence": "Presenza ricca di Discord",
"browse_all": "Esplora tutto",
"genres": "Generi",
"explore_genres": "Esplora generi"
}

View File

@ -249,7 +249,7 @@
"developers": "開発",
"not_logged_in": "ログインしていません",
"search_mode": "検索モード",
"youtube_api_type": "APIの種類",
"audio_source": "音声ソース",
"ok": "分かりました",
"failed_to_encrypt": "暗号化に失敗しました",
"encryption_failed_warning": "Spotubeはデータを安全に保存するために暗号化を使用しています。しかし、失敗しました。したがって、安全でないストレージにフォールバックします\nLinuxを使用している場合は、gnome-keyring、kde-wallet、keepassxcなどのシークレットサービスがインストールされていることを確認してください",
@ -279,5 +279,10 @@
"password": "パスワード",
"login": "ログインする",
"login_with_your_lastfm": "あなたのLast.fmアカウントでログインする",
"scrobble_to_lastfm": "Last.fmにスクロブルする"
"scrobble_to_lastfm": "Last.fmにスクロブルする",
"go_to_album": "アルバムに移動",
"discord_rich_presence": "ディスコードリッチプレゼンス",
"browse_all": "すべてを閲覧",
"genres": "ジャンル",
"explore_genres": "ジャンルを探索"
}

289
lib/l10n/app_nl.arb Normal file
View File

@ -0,0 +1,289 @@
{
"guest": "Gast",
"browse": "Bladeren",
"search": "Zoek op",
"library": "Bibliotheek",
"lyrics": "Liedteksten",
"settings": "Instellingen",
"genre_categories_filter": "Categorieën of genres filteren...",
"genre": "Genre",
"personalized": "Gepersonaliseerd",
"featured": "Aanbevolen",
"new_releases": "Nieuwe uitgaves",
"songs": "Liedjes",
"playing_track": "{track} afspelen",
"queue_clear_alert": "Dit zal de huidige wachtrij wissen. {track_length} tracks worden verwijderd\nWilt u doorgaan?",
"load_more": "Meer laden",
"playlists": "Afspeellijsten",
"artists": "Kunstenaars",
"albums": "Albums",
"tracks": "Nummers",
"downloads": "Downloads",
"filter_playlists": "Filter uw afspeellijsten...",
"liked_tracks": "Geliefde tracks",
"liked_tracks_description": "Al je favoriete nummers",
"create_playlist": "Afspeellijst maken",
"create_a_playlist": "Een afspeellijst maken",
"update_playlist": "Afspeellijst bijwerken",
"create": "Maak",
"cancel": "Annuleren",
"update": "Bijwerken",
"playlist_name": "Afspeellijstnaam",
"name_of_playlist": "Naam van de afspeellijst",
"description": "Beschrijving",
"public": "Openbaar",
"collaborative": "Samenwerkend",
"search_local_tracks": "Lokale nummers zoeken...",
"play": "Speel",
"delete": "Wissen",
"none": "Geen",
"sort_a_z": "Sorteren op A-Z",
"sort_z_a": "Sorteren op Z-A",
"sort_artist": "Sorteren op kunstenaar",
"sort_album": "Sorteren op album",
"sort_tracks": "Nummers sorteren",
"currently_downloading": "Momenteel aan het downloaden ({tracks_length})",
"cancel_all": "Alle annuleren",
"filter_artist": "Kunstenaars filteren...",
"followers": "{followers} volgers",
"add_artist_to_blacklist": "Kunstenaar toevoegen aan zwarte lijst",
"top_tracks": "Topsporen",
"fans_also_like": "Liefhebbers willen ook",
"loading": "Aan het laden...",
"artist": "Kunstenaar",
"blacklisted": "Op de zwarte lijst",
"following": "Op volg",
"follow": "Volgen",
"artist_url_copied": "URL artiest gekopieerd naar klembord",
"added_to_queue": "{tracks} tracks toegevoegd aan wachtrij",
"filter_albums": "Albums filteren...",
"synced": "Gesynchroniseerd",
"plain": "Eenvoudig",
"shuffle": "Schuifelen",
"search_tracks": "Zoek nummers...",
"released": "Vrijgegeven",
"error": "Fout {error}",
"title": "Titel",
"time": "Tijd",
"more_actions": "Meer acties",
"download_count": "({count}) downloads",
"add_count_to_playlist": "Voeg ({count}) toe aan afspeellijst",
"add_count_to_queue": "Voeg ({count}) toe aan wachtrij",
"play_count_next": "Speel ({count}) volgende",
"album": "Album",
"copied_to_clipboard": "{data} naar klembord gekopieerd",
"add_to_following_playlists": "Voeg {track} toe aan volgende afspeellijsten",
"add": "Toevoegen",
"added_track_to_queue": "{track} toegevoegd aan wachtrij",
"add_to_queue": "Toevoegen aan wachtrij",
"track_will_play_next": "{track} zal hierna spelen",
"play_next": "Volgende afspelen",
"removed_track_from_queue": "{track} uit wachtrij verwijderd",
"remove_from_queue": "Verwijderen uit wachtrij",
"remove_from_favorites": "Verwijderen uit favorieten",
"save_as_favorite": "Opslaan als favoriet",
"add_to_playlist": "Toevoegen aan afspeellijst",
"remove_from_playlist": "Verwijderen uit afspeellijst",
"add_to_blacklist": "Toevoegen aan zwarte lijst",
"remove_from_blacklist": "Verwijderen uit zwarte lijst",
"share": "Delen",
"mini_player": "Minispeler",
"slide_to_seek": "Schuif om vooruit of achteruit te zoeken",
"shuffle_playlist": "Afspeellijst schuifelen",
"unshuffle_playlist": "Afspeellijst onschuifelen",
"previous_track": "Vorige nummer",
"next_track": "Volgende nummer",
"pause_playback": "Weergave pauzeren",
"resume_playback": "Weergave hervatten",
"loop_track": "Nummer loopen",
"repeat_playlist": "Afspeellijst herhalen",
"queue": "Wachtrij",
"alternative_track_sources": "Alternatieve nummerbronnen",
"download_track": "Nummer downloaden",
"tracks_in_queue": "{tracks} tracks in wachtrij",
"clear_all": "Wis alles",
"show_hide_ui_on_hover": "UI tonen/verbergen bij zweven",
"always_on_top": "Altijd bovenaan",
"exit_mini_player": "Minispeler afsluiten",
"download_location": "Downloadlocatie",
"account": "Account",
"login_with_spotify": "Inloggen met je Spotify-account",
"connect_with_spotify": "Verbinden met Spotify",
"logout": "Afmelden",
"logout_of_this_account": "Afmelden van dit account",
"language_region": "Taal & Regio",
"language": "Taal",
"system_default": "Systeemstandaard",
"market_place_region": "Marktplaats-regio",
"recommendation_country": "Aanbeveling Land",
"appearance": "Uiterlijk",
"layout_mode": "Opmaakmodus",
"override_layout_settings": "Instellingen voor responsieve opmaakmodus opheffen",
"adaptive": "Aanpassingsgericht",
"compact": "Compact",
"extended": "Uitgebreide",
"theme": "Thema",
"dark": "Donker",
"light": "Licht",
"system": "Systeem",
"accent_color": "Accentkleur",
"sync_album_color": "Albumkleur synchroniseren",
"sync_album_color_description": "Gebruikt de overheersende kleur van het albumartikel als accentkleur",
"playback": "Weergave",
"audio_quality": "Audiokwaliteit",
"high": "Hoog",
"low": "Laag",
"pre_download_play": "Vooraf downloaden en spelen",
"pre_download_play_description": "In plaats van audio te streamen, kun je bytes downloaden en afspelen (aanbevolen voor gebruikers met een hogere bandbreedte)",
"skip_non_music": "Niet-muzieksegmenten overslaan (SponsorBlock)",
"blacklist_description": "Nummers en artiesten op de zwarte lijst",
"wait_for_download_to_finish": "Wacht tot de huidige download is voltooid",
"desktop": "Bureaublad",
"close_behavior": "Sluitgedrag",
"close": "Sluit af",
"minimize_to_tray": "Minimaliseren naar lade",
"show_tray_icon": "Systeemvakpictogram tonen",
"about": "Over",
"u_love_spotube": "We weten dat jullie van Spotube houden",
"check_for_updates": "Controleren op updates",
"about_spotube": "Over Spotube",
"blacklist": "Zwarte lijst",
"please_sponsor": "Sponsor/Doneer a.u.b.",
"spotube_description": "Spotube, een lichtgewicht, cross-platform, vrij-voor-alles Spotify-client",
"version": "Versie",
"build_number": "Beeldnummer",
"founder": "Stichter",
"repository": "Opslagplaats",
"bug_issues": "Bug+problemen",
"made_with": "Gemaakt met ❤️ in Bangladesh🇧🇩",
"kingkor_roy_tirtho": "Kingkor Roy Tirtho",
"copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
"license": "Licentie",
"add_spotify_credentials": "Voeg je spotify-referenties toe om te beginnen",
"credentials_will_not_be_shared_disclaimer": "Maakt u geen zorgen, uw gegevens worden niet verzameld of gedeeld met anderen.",
"know_how_to_login": "Weet u niet hoe u dit moet doen?",
"follow_step_by_step_guide": "Volg de stap voor stap gids",
"spotify_cookie": "Spotify {name} Cookie",
"cookie_name_cookie": "{name} Cookie",
"fill_in_all_fields": "Vul alle velden in a.u.b.",
"submit": "Verzenden",
"exit": "Ga weg",
"previous": "Vorige",
"next": "Volgende",
"done": "Klaar",
"step_1": "Stap 1",
"first_go_to": "Ga eerst naar",
"login_if_not_logged_in": "en Inloggen/Aanmelden als u niet bent ingelogd",
"step_2": "Stap 2",
"step_2_steps": "1. Zodra je bent aangemeld, druk je op F12 of klik je met de rechtermuisknop > Inspect om de Browser devtools te openen.\n2. Ga vervolgens naar het tabblad \"Toepassing\" (Chrome, Edge, Brave enz..) of naar het tabblad \"Opslag\" (Firefox, Palemoon enz..).\n3. Ga naar de sectie \"Cookies\" en vervolgens naar de subsectie \"https://accounts.spotify.com\".",
"step_3": "Stap 3",
"step_3_steps": "Kopieer de waarden van \"sp_dc\" en \"sp_key\" (of sp_gaid) Cookies",
"success_emoji": "Succes🥳",
"success_message": "Je bent nu succesvol ingelogd met je Spotify account. Goed gedaan, maat!",
"step_4": "Stap 4",
"step_4_steps": "Plak de gekopieerde \"sp_dc\" en \"sp_key\" (of sp_gaid) waarden in de respectievelijke velden",
"something_went_wrong": "Er ging iets mis",
"piped_instance": "Piped-serverinstantie",
"piped_description": "De Piped-serverinstantie die moet worden gebruikt voor het matchen van sporen",
"piped_warning": "Sommige werken misschien niet goed. Dus gebruik ze op eigen risico",
"generate_playlist": "Afspeellijst genereren",
"track_exists": "Nummer {track} bestaat al",
"replace_downloaded_tracks": "Alle gedownloade nummers vervangen",
"skip_download_tracks": "Downloaden van alle gedownloade nummers overslaan",
"do_you_want_to_replace": "Wil je de bestaande nummer vervangen?",
"replace": "Vervangen",
"skip": "Overslaan",
"select_up_to_count_type": "Selecteer tot {count} {type}",
"select_genres": "Genres selecteren",
"add_genres": "Genres toevoegen",
"country": "Land",
"number_of_tracks_generate": "Aantal nummers om te genereren",
"acousticness": "Akoesticiteit",
"danceability": "Dansbaarheid",
"energy": "Energie",
"instrumentalness": "Instrumentaliteit",
"liveness": "Levendigheid",
"loudness": "Luidheid",
"speechiness": "Sprakeligheid",
"valence": "Valentie",
"popularity": "Populariteit",
"key": "Sleutel",
"duration": "Tijdsduur (s)",
"tempo": "Tempo (SPM)",
"mode": "Modus",
"time_signature": "Tijdsnotatie",
"short": "Kort",
"medium": "Middel",
"long": "Lang",
"min": "Min",
"max": "Max",
"target": "Doel",
"moderate": "Matig",
"deselect_all": "Alles deselecteren",
"select_all": "Alles selecteren",
"are_you_sure": "Weet je het zeker?",
"generating_playlist": "Je aangepaste afspeellijst genereren...",
"selected_count_tracks": "{count} nummers geselecteerd",
"download_warning": "Als je alle Tracks in bulk downloadt, ben je duidelijk bezig met muziekpiraterij en breng je schade toe aan de creatieve muziekmaatschappij. Ik hoop dat je je hiervan bewust bent. Probeer altijd het harde werk van artiesten te respecteren en te steunen.",
"download_ip_ban_warning": "BTW, je IP-adres kan worden geblokkeerd op YouTube als gevolg van buitensporige downloadverzoeken dan normaal. IP blokkering betekent dat je YouTube niet kunt gebruiken (zelfs als je ingelogd bent) voor tenminste 2-3 maanden vanaf dat IP apparaat. Spotube is niet verantwoordelijk als dit ooit gebeurt.",
"by_clicking_accept_terms": "Door op 'accepteren' te klikken ga je akkoord met de volgende voorwaarden:",
"download_agreement_1": "Ik weet dat ik muziek illegaal verveel. Ik ben en crimineel.",
"download_agreement_2": "Ik steun de kunstenaar waar ik kan en ik doe dit alleen omdat ik geen geld heb om hun kunst te kopen.",
"download_agreement_3": "Ik ben me er volledig van bewust dat mijn IP geblokkeerd kan worden op YouTube & ik houd Spotube of zijn eigenaars/contributeurs niet verantwoordelijk voor ongelukken die veroorzaakt worden door mijn huidige actie.",
"decline": "Weigeren",
"accept": "Accepteren",
"details": "Bijzonderheden",
"youtube": "YouTube",
"channel": "Kanaal",
"likes": "Liefs",
"dislikes": "Hekels",
"views": "Weergaven",
"streamUrl": "Stream-URL",
"stop": "Stoppen",
"sort_newest": "Sorteren op nieuwste toegevoegd",
"sort_oldest": "Sorteren op oudste toegevoegd",
"sleep_timer": "Slaaptimer",
"mins": "{minutes} minuten",
"hours": "{hours} uren",
"hour": "{hours} uur",
"custom_hours": "Aangepaste uren",
"logs": "Logboeken",
"developers": "Ontwikkelaars",
"not_logged_in": "U bent niet aangemeld",
"search_mode": "Zoekmodus",
"youtube_api_type": "API-type",
"ok": "Oké",
"failed_to_encrypt": "Versleuteling mislukt",
"encryption_failed_warning": "Spotube gebruikt encryptie om je gegevens veilig op te slaan. Maar dat is niet gelukt. Dus zal het terugvallen op onveilige opslag.\nAls je linux gebruikt, zorg er dan voor dat je een geheim-dienst (gnome-keyring, kde-wallet, keepassxc etc) hebt geïnstalleerd.",
"querying_info": "Info opvragen...",
"piped_api_down": "Piped API is uit",
"piped_down_error_instructions": "De Piped-instantie {pipedInstance} is momenteel uitgevallen\n\nVerander de instantie of verander het 'API-type' naar de officiële YouTube API.\n\nZorg ervoor dat u de app herstart na de wijziging",
"you_are_offline": "U bent momenteel offline",
"connection_restored": "Uw internetverbinding is hersteld",
"use_system_title_bar": "Systeemtitelbalk gebruiken",
"crunching_results": "Resultaten kraken...",
"search_to_get_results": "Zoek om resultaten te krijgen",
"use_amoled_mode": "Pikzwart donkerthema",
"pitch_dark_theme": "AMOLED-modus",
"normalize_audio": "Audio normaliseren",
"change_cover": "Dekking wijzigen",
"add_cover": "Dekking toevoegen",
"restore_defaults": "Standaardwaarden herstellen",
"download_music_codec": "Muziek-codec downloaden",
"streaming_music_codec": "Muziek-codec streamen",
"login_with_lastfm": "Aanmelden met Last.fm",
"connect": "Verbinden",
"disconnect_lastfm": "Last.fm verbreken",
"disconnect": "Ontkoppelen",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"login": "Inloggen",
"login_with_your_lastfm": "Inloggen met uw Last.fm account",
"scrobble_to_lastfm": "Scrobbel naar Last.fm",
"audio_source": "Audiobron",
"go_to_album": "Ga naar album",
"discord_rich_presence": "Discord Rich Presence",
"browse_all": "Alles bekijken",
"genres": "Genres",
"explore_genres": "Verken genres"
}

View File

@ -249,7 +249,7 @@
"developers": "Developerzy",
"not_logged_in": "Nie jesteś zalogowany",
"search_mode": "Tryb szukania",
"youtube_api_type": "Typ API",
"audio_source": "Źródło dźwięku",
"ok": "Ok",
"failed_to_encrypt": "Nie można zaszyfrować :(",
"encryption_failed_warning": "Spotube używa szyfrowania do bezpiecznego przechowywania danych. Ale nie udało się tego zrobić. Więc powróci do niezabezpieczonego przechowywania\nJeśli używasz Linuksa, upewnij się, że masz zainstalowane jakieś usługi do szyfrowania (gnome-keyring, kde-wallet, keepassxc itp.)",
@ -279,5 +279,10 @@
"password": "Hasło",
"login": "Zaloguj",
"login_with_your_lastfm": "Zaloguj się na swoje konto Last.fm",
"scrobble_to_lastfm": "Scrobbluj do Last.fm"
"scrobble_to_lastfm": "Scrobbluj do Last.fm",
"go_to_album": "Przejdź do albumu",
"discord_rich_presence": "Obecność na Discordzie",
"browse_all": "Przeglądaj wszystko",
"genres": "Gatunki muzyczne",
"explore_genres": "Eksploruj gatunki"
}

View File

@ -249,7 +249,7 @@
"developers": "Desenvolvedores",
"not_logged_in": "Você não está logado",
"search_mode": "Modo de Busca",
"youtube_api_type": "Tipo de API",
"audio_source": "Fonte de Áudio",
"ok": "Ok",
"failed_to_encrypt": "Falha ao criptografar",
"encryption_failed_warning": "O Spotube usa criptografia para armazenar seus dados com segurança, mas falhou em fazê-lo. Portanto, ele voltará para o armazenamento não seguro.\nSe você estiver usando o Linux, certifique-se de ter algum serviço secreto (gnome-keyring, kde-wallet, keepassxc, etc.) instalado",
@ -279,5 +279,10 @@
"password": "Palavra-passe",
"login": "Iniciar sessão",
"login_with_your_lastfm": "Inicie sessão na sua conta Last.fm",
"scrobble_to_lastfm": "Scrobble para o Last.fm"
"scrobble_to_lastfm": "Scrobble para o Last.fm",
"go_to_album": "Ir para o álbum",
"discord_rich_presence": "Presença rica no Discord",
"browse_all": "Navegar por tudo",
"genres": "Gêneros",
"explore_genres": "Explorar gêneros"
}

View File

@ -249,7 +249,7 @@
"developers": "Разработчики",
"not_logged_in": "Вы не выполнили вход",
"search_mode": "Режим поиска",
"youtube_api_type": "Тип API",
"audio_source": "Источник аудио",
"ok": "Ок",
"failed_to_encrypt": "Не удалось зашифровать",
"encryption_failed_warning": "Spotube использует шифрование для безопасного хранения ваших данных. Однако в этом случае произошла ошибка. Поэтому будет использовано небезопасное хранилище.\nЕсли вы используете Linux, убедитесь, что у вас установлен какой-либо инструмент для работы с секретами (gnome-keyring, kde-wallet, keepassxc и т.д.)",
@ -279,5 +279,10 @@
"password": "Пароль",
"login": "Войти",
"login_with_your_lastfm": "Войти в свою учетную запись Last.fm",
"scrobble_to_lastfm": "Скробблинг на Last.fm"
"scrobble_to_lastfm": "Скробблинг на Last.fm",
"go_to_album": "Перейти к альбому",
"discord_rich_presence": "Богатое присутствие в Discord",
"browse_all": "Просмотреть все",
"genres": "Жанры",
"explore_genres": "Исследовать жанры"
}

View File

@ -251,7 +251,7 @@
"developers": "Geliştiriciler",
"not_logged_in": "Giriş yapmadınız",
"search_mode": "Arama Modu",
"youtube_api_type": "API Türü",
"audio_source": "Ses Kaynağı",
"ok": "Tamam",
"failed_to_encrypt": "Şifreleme başarısız oldu",
"encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.",
@ -279,5 +279,10 @@
"password": "Şifre",
"login": "Giriş Yap",
"login_with_your_lastfm": "Last.fm hesabınız ile giriş yapın",
"scrobble_to_lastfm": "Last.fm için Scrobble"
"scrobble_to_lastfm": "Last.fm için Scrobble",
"go_to_album": "Albüme Git",
"discord_rich_presence": "Discord Zengin Varlık",
"browse_all": "Tümünü Gözat",
"genres": "Müzik Türleri",
"explore_genres": "Türleri Keşfet"
}

View File

@ -251,7 +251,7 @@
"developers": "Розробники",
"not_logged_in": "Ви не ввійшли в обліковий запис",
"search_mode": "Режим пошуку",
"youtube_api_type": "Тип API",
"audio_source": "Джерело аудіо",
"ok": "Гаразд",
"failed_to_encrypt": "Не вдалося зашифрувати",
"encryption_failed_warning": "Spotube використовує шифрування для безпечного зберігання ваших даних. Але не вдалося цього зробити. Тому він перейде до небезпечного зберігання\nЯкщо ви використовуєте Linux, переконайтеся, що у вас встановлено будь-який секретний сервіс (gnome-keyring, kde-wallet, keepassxc тощо)",
@ -279,5 +279,10 @@
"password": "Пароль",
"login": "Увійти",
"login_with_your_lastfm": "Увійти в свій обліковий запис Last.fm",
"scrobble_to_lastfm": "Скробблінг на Last.fm"
"scrobble_to_lastfm": "Скробблінг на Last.fm",
"go_to_album": "Перейти до альбому",
"discord_rich_presence": "Багата присутність у Discord",
"browse_all": "Переглянути все",
"genres": "Жанри",
"explore_genres": "Досліджувати жанри"
}

View File

@ -249,7 +249,7 @@
"developers": "开发者",
"not_logged_in": "你尚未登录",
"search_mode": "搜索模式",
"youtube_api_type": "API 类型",
"audio_source": "音频源",
"ok": "确定",
"failed_to_encrypt": "加密失败",
"encryption_failed_warning": "Spotube使用加密来安全地存储您的数据。但是失败了。因此它将回退到不安全的存储\n如果您使用Linux请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务",
@ -279,5 +279,10 @@
"password": "密码",
"login": "登录",
"login_with_your_lastfm": "使用您的 Last.fm 帐户登录",
"scrobble_to_lastfm": "在 Last.fm 上记录播放"
"scrobble_to_lastfm": "在 Last.fm 上记录播放",
"go_to_album": "前往专辑",
"discord_rich_presence": "Discord 丰富展现",
"browse_all": "浏览全部",
"genres": "音乐类型",
"explore_genres": "探索音乐类型"
}

View File

@ -8,6 +8,7 @@
/// yuri-val@github => Ukrainian
/// energywave@github, ncvescera@github, OpenCode@github => Italian
/// mdksec@github => Turkish
/// SecularSteve@github => Dutch
import 'package:flutter/material.dart';
class L10n {
@ -23,6 +24,7 @@ class L10n {
const Locale('hi', 'IN'),
const Locale('it', 'IT'),
const Locale('ja', 'JP'),
const Locale('nl', 'NL'),
const Locale('pl', 'PL'),
const Locale('pt', 'PT'),
const Locale('ru', 'RU'),

View File

@ -13,8 +13,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/collections/initializers.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/intents.dart';
import 'package:spotube/hooks/configurators/use_close_behavior.dart';
import 'package:spotube/hooks/configurators/use_deep_linking.dart';
import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart';
import 'package:spotube/hooks/configurators/use_get_storage_perms.dart';
import 'package:spotube/l10n/l10n.dart';
@ -40,6 +43,8 @@ Future<void> main(List<String> rawArgs) async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
await registerWindowsScheme("spotify");
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
MediaKit.ensureInitialized();
@ -49,6 +54,10 @@ Future<void> main(List<String> rawArgs) async {
await FlutterDisplayMode.setHighRefreshRate();
}
if (DesktopTools.platform.isDesktop) {
await DesktopTools.window.setPreventClose(true);
}
await DesktopTools.ensureInitialized(
DesktopWindowOptions(
hideTitleBar: true,
@ -176,7 +185,11 @@ class SpotubeState extends ConsumerState<Spotube> {
final paletteColor =
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
useDisableBatteryOptimizations();
useInitSysTray(ref);
useDeepLinking(ref);
useCloseBehavior(ref);
useGetStoragePermissions(ref);
useEffect(() {
FlutterNativeSplash.remove();
@ -184,13 +197,9 @@ class SpotubeState extends ConsumerState<Spotube> {
/// For enabling hot reload for audio player
if (!kDebugMode) return;
audioPlayer.dispose();
// youtube.close();
};
}, []);
useDisableBatteryOptimizations();
useGetStoragePermissions(ref);
final lightTheme = useMemoized(
() => theme(paletteColor ?? accentMaterialColor, Brightness.light, false),
[paletteColor, accentMaterialColor],
@ -201,7 +210,7 @@ class SpotubeState extends ConsumerState<Spotube> {
Brightness.dark,
isAmoledTheme,
),
[paletteColor, accentMaterialColor, isAmoledTheme],
);
return MaterialApp.router(

View File

@ -2,10 +2,10 @@ 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:skeletonizer/skeletonizer.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/artist/artist_album_list.dart';
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/pages/artist/section/footer.dart';
@ -35,45 +35,46 @@ class ArtistPage extends HookConsumerWidget {
),
extendBodyBehindAppBar: true,
body: Builder(builder: (context) {
if (artistQuery.isLoading || !artistQuery.hasData) {
const ShimmerArtistProfile();
} else if (artistQuery.hasError) {
if (artistQuery.hasError) {
return Center(child: Text(artistQuery.error.toString()));
}
return CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: SafeArea(
bottom: false,
child: ArtistPageHeader(artistId: artistId),
),
),
const SliverGap(50),
ArtistPageTopTracks(artistId: artistId),
const SliverGap(50),
SliverToBoxAdapter(child: ArtistAlbumList(artistId)),
const SliverGap(20),
SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.fans_also_like,
style: theme.textTheme.headlineSmall,
return Skeletonizer(
enabled: artistQuery.isLoading,
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: SafeArea(
bottom: false,
child: ArtistPageHeader(artistId: artistId),
),
),
),
SliverSafeArea(
sliver: ArtistPageRelatedArtists(artistId: artistId),
),
if (artistQuery.data != null)
SliverSafeArea(
top: false,
const SliverGap(50),
ArtistPageTopTracks(artistId: artistId),
const SliverGap(50),
SliverToBoxAdapter(child: ArtistAlbumList(artistId)),
const SliverGap(20),
SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: SliverToBoxAdapter(
child: ArtistPageFooter(artist: artistQuery.data!),
child: Text(
context.l10n.fans_also_like,
style: theme.textTheme.headlineSmall,
),
),
),
],
SliverSafeArea(
sliver: ArtistPageRelatedArtists(artistId: artistId),
),
if (artistQuery.data != null)
SliverSafeArea(
top: false,
sliver: SliverToBoxAdapter(
child: ArtistPageFooter(artist: artistQuery.data!),
),
),
],
),
);
}),
),

View File

@ -4,7 +4,9 @@ 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:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
@ -25,7 +27,7 @@ class ArtistPageHeader extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final queryClient = useQueryClient();
final artistQuery = useQueries.artist.get(ref, artistId);
final artist = artistQuery.data;
final artist = artistQuery.data ?? FakeData.artist;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context);
@ -41,10 +43,6 @@ class ArtistPageHeader extends HookConsumerWidget {
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);
@ -96,10 +94,12 @@ class ArtistPageHeader extends HookConsumerWidget {
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(50)),
child: Text(
artist.type!.toUpperCase(),
style: chipTextVariant.copyWith(
color: Colors.white,
child: Skeleton.keep(
child: Text(
artist.type!.toUpperCase(),
style: chipTextVariant.copyWith(
color: Colors.white,
),
),
),
),
@ -138,113 +138,115 @@ class ArtistPageHeader extends HookConsumerWidget {
),
),
const Gap(20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (auth != null)
HookBuilder(
builder: (context) {
final isFollowingQuery =
useQueries.artist.doIFollow(ref, artistId);
Skeleton.keep(
child: 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();
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",
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(),
);
}
}, [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),
);
}
if (isFollowingQuery.data!) {
return OutlinedButton(
return FilledButton(
onPressed: followUnfollow,
child: Text(context.l10n.following),
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!),
);
}
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!,
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,
),
),
);
}
if (!context.mounted) return;
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
context.l10n.artist_url_copied,
textAlign: TextAlign.center,
),
),
);
},
)
],
},
)
],
),
)
],
),

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart';
@ -28,11 +30,7 @@ class ArtistPageTopTracks extends HookConsumerWidget {
topTracksQuery.data ?? <Track>[],
);
if (topTracksQuery.isLoading || !topTracksQuery.hasData) {
return const SliverToBoxAdapter(
child: Center(child: CircularProgressIndicator()),
);
} else if (topTracksQuery.hasError) {
if (topTracksQuery.hasError) {
return SliverToBoxAdapter(
child: Center(
child: Text(topTracksQuery.error.toString()),
@ -40,7 +38,8 @@ class ArtistPageTopTracks extends HookConsumerWidget {
);
}
final topTracks = topTracksQuery.data!;
final topTracks =
topTracksQuery.data ?? List.generate(10, (index) => FakeData.track);
void playPlaylist(List<Track> tracks, {Track? currentTrack}) async {
currentTrack ??= tracks.first;
@ -92,9 +91,11 @@ class ArtistPageTopTracks extends HookConsumerWidget {
),
const SizedBox(width: 5),
IconButton(
icon: Icon(
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
color: Colors.white,
icon: Skeleton.keep(
child: Icon(
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
color: Colors.white,
),
),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.primary,

View File

@ -1,125 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.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/genre/category_card.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/shimmers/shimmer_categories.dart';
import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class GenrePage extends HookConsumerWidget {
const GenrePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final scrollController = useScrollController();
final recommendationMarket = ref.watch(
userPreferencesProvider.select((s) => s.recommendationMarket),
);
final categoriesQuery = useQueries.category.list(ref, recommendationMarket);
final isFiltering = useState(false);
final isMounted = useIsMounted();
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
useValueListenable(searchController);
final categories = useMemoized(
() {
final categories = categoriesQuery.pages
.expand<Category>(
(page) => page.items ?? const Iterable.empty(),
)
.toList();
if (searchController.text.isEmpty) {
return categories;
}
return categories
.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();
},
[categoriesQuery.pages, searchController.text],
);
final list = RefreshIndicator(
onRefresh: () async {
await categoriesQuery.refreshAll();
},
child: Waypoint(
onTouchEdge: () async {
if (categoriesQuery.hasNextPage && isMounted()) {
await categoriesQuery.fetchNext();
}
},
controller: scrollController,
child: Column(
children: [
ExpandableSearchField(
isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
searchController: searchController,
searchFocus: searchFocus,
),
if (!categoriesQuery.hasPageData &&
!categoriesQuery.isLoadingNextPage)
const ShimmerCategories()
else
Expanded(
child: InfiniteList(
scrollController: scrollController,
itemCount: categories.length,
onFetchData: categoriesQuery.fetchNext,
isLoading: categoriesQuery.isLoadingNextPage,
hasReachedMax: !categoriesQuery.hasNextPage,
loadingBuilder: (context) => const ShimmerCategories(),
itemBuilder: (context, index) {
return CategoryCard(categories[index]);
},
),
),
],
),
),
);
return Stack(
children: [
Positioned.fill(child: list),
Positioned(
top: 0,
right: 10,
child: ExpandableSearchButton(
isFiltering: isFiltering.value,
searchFocus: searchFocus,
icon: const Icon(SpotubeIcons.search),
onPressed: (value) {
isFiltering.value = value;
if (isFiltering.value) {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
),
),
],
);
}
}

View File

@ -0,0 +1,174 @@
import 'dart:ui';
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:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:collection/collection.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
class GenrePlaylistsPage extends HookConsumerWidget {
final Category category;
const GenrePlaylistsPage({Key? key, required this.category})
: super(key: key);
@override
Widget build(BuildContext context, ref) {
final playlistsQuery = useQueries.category.playlistsOf(
ref,
category.id!,
);
final playlists = useMemoized(
() => playlistsQuery.pages.expand(
(page) {
return page.items?.whereNotNull() ??
const Iterable<PlaylistSimple>.empty();
},
).toList(),
[playlistsQuery.pages],
);
final mediaQuery = MediaQuery.of(context);
final scrollController = useScrollController();
return Scaffold(
appBar: DesktopTools.platform.isDesktop
? const PageWindowTitleBar(
leading: BackButton(color: Colors.white),
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
)
: null,
extendBodyBehindAppBar: true,
body: CustomScrollView(
controller: scrollController,
slivers: [
SliverAppBar(
automaticallyImplyLeading: DesktopTools.platform.isMobile,
expandedHeight: mediaQuery.mdAndDown ? 200 : 150,
pinned: true,
floating: false,
title: const Text(""),
backgroundColor: Colors.brown.withOpacity(0.7),
flexibleSpace: FlexibleSpaceBar(
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
background: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(
category.icons!.first.url!,
),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: const ColoredBox(color: Colors.transparent),
),
),
centerTitle: DesktopTools.platform.isDesktop,
title: Text(
category.name!,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
letterSpacing: 3,
shadows: [
const Shadow(
offset: Offset(-1.5, -1.5),
color: Colors.black54,
),
const Shadow(
offset: Offset(1.5, -1.5),
color: Colors.black54,
),
const Shadow(
offset: Offset(1.5, 1.5),
color: Colors.black54,
),
const Shadow(
offset: Offset(-1.5, 1.5),
color: Colors.black54,
),
],
),
),
collapseMode: CollapseMode.parallax,
),
),
const SliverGap(20),
SliverSafeArea(
top: false,
sliver: SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: mediaQuery.mdAndDown ? 12 : 24,
),
sliver: playlists.isEmpty
? Skeletonizer.sliver(
child: SliverToBoxAdapter(
child: Wrap(
spacing: 12,
runSpacing: 12,
children: List.generate(
6,
(index) => PlaylistCard(FakeData.playlist),
),
),
),
)
: SliverGrid.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 190,
mainAxisExtent: mediaQuery.mdAndDown ? 225 : 250,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: playlists.length + 1,
itemBuilder: (context, index) {
final playlist = playlists.elementAtOrNull(index);
if (playlist == null) {
if (!playlistsQuery.hasNextPage) {
return const SizedBox.shrink();
}
return Skeletonizer(
enabled: true,
child: Waypoint(
controller: scrollController,
isGrid: true,
onTouchEdge: () async {
if (playlistsQuery.hasNextPage) {
await playlistsQuery.fetchNext();
}
},
child: PlaylistCard(FakeData.playlist),
),
);
}
return Skeleton.keep(
child: PlaylistCard(playlist),
);
},
),
),
),
const SliverGap(20),
],
),
);
}
}

View File

@ -0,0 +1,98 @@
import 'dart:math';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/gradients.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/queries/queries.dart';
class GenrePage extends HookConsumerWidget {
const GenrePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final scrollController = useScrollController();
final recommendationMarket = ref.watch(
userPreferencesProvider.select((s) => s.recommendationMarket),
);
final categoriesQuery =
useQueries.category.listAll(ref, recommendationMarket);
final categories = categoriesQuery.data ?? <Category>[];
final mediaQuery = MediaQuery.of(context);
return Scaffold(
appBar: PageWindowTitleBar(
title: Text(context.l10n.explore_genres),
automaticallyImplyLeading: true,
),
body: SafeArea(
top: false,
child: GridView.builder(
padding: const EdgeInsets.all(12),
controller: scrollController,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
childAspectRatio: 9 / 18,
maxCrossAxisExtent: mediaQuery.smAndDown ? 200 : 300,
mainAxisExtent: 200,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final gradient = gradients[Random().nextInt(gradients.length)];
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
context.push("/genre/${category.id}", extra: category);
},
child: Ink(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: NetworkImage(category.icons!.first.url!),
fit: BoxFit.cover,
),
gradient: gradient,
),
child: Align(
alignment: Alignment.bottomCenter,
child: AutoSizeText(
category.name!,
style: textTheme.titleLarge?.copyWith(
color: Colors.white,
shadows: [
// stroke shadow
const Shadow(
color: Colors.black,
offset: Offset(1, 1),
blurRadius: 2,
),
],
),
maxLines: 1,
textAlign: TextAlign.center,
maxFontSize: textTheme.titleLarge!.fontSize!,
minFontSize: textTheme.titleMedium!.fontSize!,
),
),
),
);
},
),
),
);
}
}

View File

@ -1,36 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/home/sections/featured.dart';
import 'package:spotube/components/home/sections/genres.dart';
import 'package:spotube/components/home/sections/made_for_user.dart';
import 'package:spotube/components/home/sections/new_releases.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/themed_button_tab_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/home/genres.dart';
import 'package:spotube/pages/home/personalized.dart';
class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: PageWindowTitleBar(
centerTitle: true,
leadingWidth: double.infinity,
leading: ThemedButtonsTabBar(
tabs: [
Tab(text: " ${context.l10n.personalized} "),
Tab(text: " ${context.l10n.genre} "),
final controller = useScrollController();
return SafeArea(
bottom: false,
child: Scaffold(
appBar: DesktopTools.platform.isMobile
? null
: const PageWindowTitleBar(),
body: CustomScrollView(
controller: controller,
slivers: [
const HomeGenresSection(),
SliverList.list(
children: const [
HomeFeaturedSection(),
HomeNewReleasesSection(),
],
),
const SliverSafeArea(sliver: HomeMadeForUserSection()),
],
),
),
body: const TabBarView(
children: [
PersonalizedPage(),
GenrePage(),
],
),
),
);
));
}
}

View File

@ -1,110 +0,0 @@
import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/components/shared/shimmers/shimmer_categories.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PersonalizedPage extends HookConsumerWidget {
const PersonalizedPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final controller = useScrollController();
final auth = ref.watch(AuthenticationNotifier.provider);
final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
final playlists = useMemoized(
() => featuredPlaylistsQuery.pages
.whereType<Page<PlaylistSimple>>()
.expand((page) => page.items ?? const <PlaylistSimple>[]),
[featuredPlaylistsQuery.pages],
);
final madeForUser = useQueries.views.get(ref, "made-for-x-hub");
final newReleases = useQueries.album.newReleases(ref);
final userArtistsQuery = useQueries.artist.followedByMeAll(ref);
final userArtists =
userArtistsQuery.data?.map((s) => s.id!).toList() ?? const [];
final albums = useMemoized(
() => newReleases.pages
.whereType<Page<AlbumSimple>>()
.expand((page) => page.items ?? const <AlbumSimple>[])
.where((album) {
return album.artists
?.any((artist) => userArtists.contains(artist.id!)) ==
true;
})
.map((album) => TypeConversionUtils.simpleAlbum_X_Album(album))
.toList(),
[newReleases.pages],
);
return CustomScrollView(
controller: controller,
slivers: [
SliverList.list(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: !featuredPlaylistsQuery.hasPageData &&
!featuredPlaylistsQuery.isLoadingNextPage
? const ShimmerCategories()
: HorizontalPlaybuttonCardView<PlaylistSimple>(
items: playlists.toList(),
title: Text(context.l10n.featured),
isLoadingNextPage:
featuredPlaylistsQuery.isLoadingNextPage,
hasNextPage: featuredPlaylistsQuery.hasNextPage,
onFetchMore: featuredPlaylistsQuery.fetchNext,
),
),
if (auth != null)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: newReleases.hasPageData &&
userArtistsQuery.hasData &&
!newReleases.isLoadingNextPage
? HorizontalPlaybuttonCardView<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: () {},
);
},
),
),
],
);
}
}

View File

@ -77,7 +77,7 @@ class SyncedLyrics extends HookConsumerWidget {
: textTheme.headlineMedium?.copyWith(fontSize: 25))
?.copyWith(color: palette.titleTextColor);
var bodyTextTheme = textTheme.bodyLarge?.copyWith(
final bodyTextTheme = textTheme.bodyLarge?.copyWith(
color: palette.bodyTextColor,
);
return Stack(
@ -184,7 +184,9 @@ class SyncedLyrics extends HookConsumerWidget {
),
if (playlist.activeTrack != null &&
(timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing))
const Expanded(child: ShimmerLyrics())
const Expanded(
child: ShimmerLyrics(),
)
else if (playlist.activeTrack != null &&
(timedLyricsQuery.hasError))
Text(

View File

@ -31,6 +31,9 @@ class LikedPlaylistPage extends HookConsumerWidget {
onFetchAll: () async {
return tracks.toList();
},
onRefresh: () async {
await likedTracks.refresh();
},
),
title: playlist.name!,
description: playlist.description,

View File

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

View File

@ -1,4 +1,5 @@
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/spotube_icons.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart';
@ -50,6 +51,13 @@ class SettingsDesktopSection extends HookConsumerWidget {
value: preferences.systemTitleBar,
onChanged: preferencesNotifier.setSystemTitleBar,
),
if (!DesktopTools.platform.isMacOS)
SwitchListTile(
secondary: const Icon(SpotubeIcons.discord),
title: Text(context.l10n.discord_rich_presence),
value: preferences.discordPresence,
onChanged: preferencesNotifier.setDiscordPresence,
),
],
);
}

View File

@ -51,7 +51,7 @@ class SettingsPlaybackSection extends HookConsumerWidget {
),
AdaptiveSelectTile<AudioSource>(
secondary: const Icon(SpotubeIcons.api),
title: Text(context.l10n.youtube_api_type),
title: Text(context.l10n.audio_source),
value: preferences.audioSource,
options: AudioSource.values
.map((e) => DropdownMenuItem(

View File

@ -0,0 +1,70 @@
import 'package:dart_discord_rpc/dart_discord_rpc.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class Discord extends ChangeNotifier {
final DiscordRPC? discordRPC;
final bool isEnabled;
Discord(this.isEnabled)
: discordRPC = (DesktopTools.platform.isWindows ||
DesktopTools.platform.isLinux) &&
isEnabled
? DiscordRPC(applicationId: Env.discordAppId)
: null {
discordRPC?.start(autoRegister: true);
}
void updatePresence(Track track) {
clear();
final artistNames =
TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[]);
discordRPC?.updatePresence(
DiscordPresence(
details: "Song: ${track.name} by $artistNames",
state: "Vibing in Music",
startTimeStamp: DateTime.now().millisecondsSinceEpoch,
largeImageKey: "spotube-logo-foreground",
largeImageText: "Spotube",
smallImageKey: "spotube-logo-foreground",
smallImageText: "Spotube",
),
);
}
void clear() {
discordRPC?.clearPresence();
}
void shutdown() {
discordRPC?.shutDown();
}
@override
void dispose() {
clear();
shutdown();
super.dispose();
}
}
final discordProvider = ChangeNotifierProvider(
(ref) {
final isEnabled =
ref.watch(userPreferencesProvider.select((s) => s.discordPresence));
final playback = ref.read(ProxyPlaylistNotifier.provider);
final discord = Discord(isEnabled);
if (playback.activeTrack != null) {
discord.updatePresence(playback.activeTrack!);
}
return discord;
},
);

View File

@ -24,10 +24,12 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_services/audio_services.dart';
import 'package:spotube/services/discord/discord.dart';
import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -62,6 +64,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
ProxyPlaylist get playlist => state;
BlackListNotifier get blacklist =>
ref.read(BlackListNotifier.provider.notifier);
Discord get discord => ref.read(discordProvider);
static final provider =
StateNotifierProvider<ProxyPlaylistNotifier, ProxyPlaylist>(
@ -161,8 +164,8 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
return;
}
try {
final isNotYTMode = preferences.audioSource != AudioSource.youtube ||
(preferences.audioSource == AudioSource.piped &&
final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack &&
(state.activeTrack is PipedSourcedTrack &&
preferences.searchMode == SearchMode.youtubeMusic);
if (isNotYTMode || !preferences.skipNonMusic) return;

View File

@ -43,7 +43,7 @@ class ScrobblerNotifier extends PersistedStateNotifier<ScrobblerState?> {
_scrobbleController.stream.listen((track) async {
try {
await state?.scrobblenaut.track.scrobble(
artist: TypeConversionUtils.artists_X_String(track.artists!),
artist: track.artists!.first.name!,
track: track.name!,
album: track.album!.name!,
chosenByUser: true,

View File

@ -110,6 +110,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
}
}
void setDiscordPresence(bool discordPresence) {
state = state.copyWith(discordPresence: discordPresence);
}
void setAmoledDarkTheme(bool isAmoled) {
state = state.copyWith(amoledDarkTheme: isAmoled);
}

View File

@ -198,6 +198,9 @@ final class UserPreferences {
)
final SourceCodecs downloadMusicCodec;
@JsonKey(defaultValue: true)
final bool discordPresence;
UserPreferences({
required this.audioQuality,
required this.albumColorSync,
@ -219,6 +222,7 @@ final class UserPreferences {
required this.audioSource,
required this.streamMusicCodec,
required this.downloadMusicCodec,
required this.discordPresence,
});
factory UserPreferences.withDefaults() {
@ -255,6 +259,7 @@ final class UserPreferences {
SourceCodecs? downloadMusicCodec,
SourceCodecs? streamMusicCodec,
bool? systemTitleBar,
bool? discordPresence,
}) {
return UserPreferences(
themeMode: themeMode ?? this.themeMode,
@ -277,6 +282,7 @@ final class UserPreferences {
normalizeAudio: normalizeAudio ?? this.normalizeAudio,
streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec,
systemTitleBar: systemTitleBar ?? this.systemTitleBar,
discordPresence: discordPresence ?? this.discordPresence,
);
}
}

View File

@ -63,6 +63,7 @@ UserPreferences _$UserPreferencesFromJson(Map<String, dynamic> json) =>
_$SourceCodecsEnumMap, json['downloadMusicCodec'],
unknownValue: SourceCodecs.m4a) ??
SourceCodecs.m4a,
discordPresence: json['discordPresence'] as bool? ?? true,
);
Map<String, dynamic> _$UserPreferencesToJson(UserPreferences instance) =>
@ -88,6 +89,7 @@ Map<String, dynamic> _$UserPreferencesToJson(UserPreferences instance) =>
'audioSource': _$AudioSourceEnumMap[instance.audioSource]!,
'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!,
'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!,
'discordPresence': instance.discordPresence,
};
const _$SourceQualitiesEnumMap = {

View File

@ -1,44 +0,0 @@
import 'package:dart_discord_rpc/dart_discord_rpc.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class Discord {
final DiscordRPC? discordRPC;
Discord()
: discordRPC =
DesktopTools.platform.isWindows || DesktopTools.platform.isLinux
? DiscordRPC(applicationId: Env.discordAppId)
: null {
discordRPC?.start(autoRegister: true);
}
void updatePresence(Track track) {
clear();
final artistNames =
TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[]);
discordRPC?.updatePresence(
DiscordPresence(
details: "Song: ${track.name} by $artistNames",
state: "Vibing in Music",
startTimeStamp: DateTime.now().millisecondsSinceEpoch,
largeImageKey: "spotube-logo-foreground",
largeImageText: "Spotube",
smallImageKey: "spotube-logo-foreground",
smallImageText: "Spotube",
),
);
}
void clear() {
discordRPC?.clearPresence();
}
void shutdown() {
discordRPC?.shutDown();
}
}
final discord = Discord();

View File

@ -5,12 +5,37 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart';
import 'package:spotube/hooks/spotify/use_spotify_query.dart';
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class CategoryQueries {
const CategoryQueries();
Query<List<Category>, dynamic> listAll(
WidgetRef ref,
Market recommendationMarket,
) {
ref.watch(userPreferencesProvider.select((s) => s.locale));
final locale = useContext().l10n.localeName;
final query = useSpotifyQuery<List<Category>, dynamic>(
"category-playlists",
(spotify) async {
final categories = await spotify.categories
.list(
country: recommendationMarket,
locale: locale,
)
.all();
return categories.toList()..shuffle();
},
ref: ref,
);
return query;
}
InfiniteQuery<Page<Category>, dynamic, int> list(
WidgetRef ref,
Market recommendationMarket,

View File

@ -52,6 +52,7 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) {
),
sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay),
searchBarTheme: SearchBarThemeData(
textStyle: const MaterialStatePropertyAll(TextStyle(fontSize: 15)),
constraints: const BoxConstraints(maxWidth: double.infinity),
padding: const MaterialStatePropertyAll(EdgeInsets.all(8)),
backgroundColor: MaterialStatePropertyAll(

View File

@ -9,6 +9,7 @@
#include <dart_discord_rpc/dart_discord_rpc_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <gtk/gtk_plugin.h>
#include <local_notifier/local_notifier_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
@ -28,6 +29,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) local_notifier_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin");
local_notifier_plugin_register_with_registrar(local_notifier_registrar);

View File

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
dart_discord_rpc
file_selector_linux
flutter_secure_storage_linux
gtk
local_notifier
media_kit_libs_linux
screen_retriever

View File

@ -17,6 +17,13 @@ G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GList* windows = gtk_application_get_windows(GTK_APPLICATION(application));
if (windows) {
gtk_window_present(GTK_WINDOW(windows->data));
return;
}
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
@ -78,7 +85,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch
g_application_activate(application);
*exit_status = 0;
return TRUE;
return FALSE;
}
// Implements GObject::dispose.
@ -98,7 +105,7 @@ static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID,
"flags", G_APPLICATION_NON_UNIQUE,
"com.github.KRTirtho.Spotube", APPLICATION_ID,
"flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN,
nullptr));
}

View File

@ -11,3 +11,6 @@ keywords:
generic_name: Music Streaming Application
categories:
- Music
supported_mime_type:
- x-scheme-handler/spotify

View File

@ -32,3 +32,6 @@ keywords:
generic_name: Music Streaming Application
categories:
- Music
supported_mime_type:
- x-scheme-handler/spotify

View File

@ -28,3 +28,6 @@ categories:
- Music
startup_notify: true
supported_mime_type:
- x-scheme-handler/spotify

View File

@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import app_links
import audio_service
import audio_session
import device_info_plus
@ -24,6 +25,7 @@ import window_manager
import window_size
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))

View File

@ -2,6 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<!-- abstract name for this URL type (you can leave it blank) -->
<string>Spotify</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- your schemes -->
<string>spotify</string>
</array>
</dict>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>

View File

@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.13.0"
app_links:
dependency: "direct main"
description:
name: app_links
sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb"
url: "https://pub.dev"
source: hosted
version: "3.5.0"
app_package_maker:
dependency: transitive
description:
@ -907,6 +915,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_sharing_intent:
dependency: "direct main"
description:
name: flutter_sharing_intent
sha256: "6eb896e6523b735e8230eeb206fd3b9f220f11ce879c2400a90b443147036ff9"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_svg:
dependency: "direct main"
description:
@ -1018,6 +1034,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.8"
gtk:
dependency: transitive
description:
name: gtk
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
url: "https://pub.dev"
source: hosted
version: "2.1.0"
hive:
dependency: "direct main"
description:
@ -1832,6 +1856,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
skeletonizer:
dependency: "direct main"
description:
name: skeletonizer
sha256: ff4c36e826efd5288d7a84e7619a6e9be8185d3064cecf101a9133762f3b401b
url: "https://pub.dev"
source: hosted
version: "0.8.0"
sky_engine:
dependency: transitive
description: flutter
@ -2238,13 +2270,13 @@ packages:
source: hosted
version: "5.0.7"
win32_registry:
dependency: transitive
dependency: "direct main"
description:
name: win32_registry
sha256: e4506d60b7244251bc59df15656a3093501c37fb5af02105a944d73eb95be4c9
sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
window_manager:
dependency: "direct main"
description:

View File

@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El
publish_to: "none"
version: 3.3.0+26
version: 3.4.0+27
homepage: https://spotube.krtirtho.dev
repository: https://github.com/KRTirtho/spotube
@ -118,6 +118,10 @@ dependencies:
url: https://github.com/Tommypop2/dart_discord_rpc.git
html_unescape: ^2.0.0
wikipedia_api: ^0.1.0
skeletonizer: ^0.8.0
app_links: ^3.5.0
win32_registry: ^1.1.2
flutter_sharing_intent: ^1.1.0
dev_dependencies:
build_runner: ^2.3.2

View File

@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <dart_discord_rpc/dart_discord_rpc_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
@ -20,6 +21,8 @@
#include <window_size/window_size_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
DartDiscordRpcPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DartDiscordRpcPlugin"));
FileSelectorWindowsRegisterWithRegistrar(

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
app_links
dart_discord_rpc
file_selector_windows
flutter_secure_storage_windows

View File

@ -3,6 +3,7 @@
#include <flutter_windows.h>
#include "resource.h"
#include "app_links/app_links_plugin_c_api.h"
namespace {
@ -105,6 +106,9 @@ Win32Window::~Win32Window() {
bool Win32Window::CreateAndShow(const std::wstring& title,
const Point& origin,
const Size& size) {
if (SendAppLinkToInstance(title)) {
return false;
}
Destroy();
const wchar_t* window_class =
@ -244,3 +248,39 @@ bool Win32Window::OnCreate() {
void Win32Window::OnDestroy() {
// No-op; provided for subclasses.
}
// app_links
bool Win32Window::SendAppLinkToInstance(const std::wstring& title) {
// Find our exact window
HWND hwnd = ::FindWindow(kWindowClassName, title.c_str());
if (hwnd) {
// Dispatch new link to current window
SendAppLink(hwnd);
// (Optional) Restore our window to front in same state
WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) };
GetWindowPlacement(hwnd, &place);
switch(place.showCmd) {
case SW_SHOWMAXIMIZED:
ShowWindow(hwnd, SW_SHOWMAXIMIZED);
break;
case SW_SHOWMINIMIZED:
ShowWindow(hwnd, SW_RESTORE);
break;
default:
ShowWindow(hwnd, SW_NORMAL);
break;
}
SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE);
SetForegroundWindow(hwnd);
// END Restore
// Window has been found, don't create another one.
return true;
}
return false;
}

View File

@ -93,6 +93,10 @@ class Win32Window {
// window handle for hosted content.
HWND child_content_ = nullptr;
// Dispatches link if any.
// This method enables our app to be with a single instance too.
// This is mandatory if you want to catch further links in same app.
bool SendAppLinkToInstance(const std::wstring& title);
};
#endif // RUNNER_WIN32_WINDOW_H_